Building a Recipe App with React Native, Redux, and TypeScript

Building a Recipe App with React Native, Redux, and TypeScript

In this tutorial, we will build a Recipe App using React Native, Redux for state management, and TypeScript for type safety. We will use the react-native-ui-lib library for styling our app. The Recipe App will allow users to view a list of recipes, add new recipes, edit existing recipes, and delete recipes.

Prerequisites

To follow along with this tutorial, make sure you have the following installed:

Step 1: Setting Up the Project

Let's start by setting up a new React Native project.

  1. Open your terminal and navigate to the directory where you want to create the project.
  2. Run the following command to create a new React Native project:
    shell
    npx react-native init RecipeApp --template react-native-template-typescript
    This command creates a new React Native project using the TypeScript template.
  3. Navigate to the project directory:
    shell
    cd RecipeApp
  4. Install the required dependencies by running the following command:
    shell
    yarn add react-redux redux redux-persist @react-native-async-storage/async-storage @reduxjs/toolkit @react-navigation/native @react-navigation/native-stack react-native-screens react-native-safe-area-context react-native-reanimated react-native-gesture-handler @react-native-community/masked-view react-native-ui-lib uuidv4 react-native-get-random-values
    This command installs the necessary packages for Redux, navigation, screen components, functionality, and the UI library we'll be using later.
  5. Install additional dependencies required by the react-native-ui-lib library:
    shell
    npx pod-install
    This command installs the necessary iOS dependencies using CocoaPods.
  6. Update the babel.config.js file with the following code:
    javascript
    module.exports = { presets: ['module:metro-react-native-babel-preset'], plugins: [ [ 'react-native-reanimated/plugin', { relativeSourceLocation: true, }, ], ], };

Step 2: Setting Up Navigation

Next, let's set up navigation in our app using the react-navigation library. We need screens to list all recipes, show details, add a recipe, and edit a recipe. For now, we'll create very basic screens just so the app doesn't break as we're building it. We'll make them actually do something in future steps.

  1. Create a new file called src/screens/AppNavigator.tsx. We'll add more here soon, but for now add the following code:
    typescript
    export type RootStackParamList = { RecipeList: undefined; RecipeDetail: {recipeId: string}; AddRecipe: undefined; EditRecipe: {recipeId: string}; };
    This sets up the the types to be used in the following steps.
  2. Create a new file called src/screens/RecipeListScreen.tsx and add the following code:
    tsx
    import React, { useEffect } from 'react'; import { View, Text, Button } from 'react-native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/AppNavigator'; type RecipeListScreenProps = { navigation: StackNavigationProp<RootStakeParamList, 'RecipeList'>; }; const RecipeListScreen: React.FC<RecipeListScreenProps> = ({ navigation }) => { return ( <View> <Text>Recipe List Screen</Text> <Button title="Add Recipe" onPress={() => navigation.navigate('AddRecipe')} /> </View> ); }; export default RecipeListScreen;
    At this point, the screen displays the text "Recipe List Screen" along with a button to navigate to the AddRecipe screen.
  3. Using the above as a guideline create additional files for RecipeDetailScreen, AddRecipeScreen, and EditRecipeScreen. Pressing the buttons on these screens can go to whatever screen you like. Remember, this content is only temporary.
  4. Once all your screens have been created, go back to AppNavigator.tsx and update it with the following code:
    tsx
    import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import React from 'react'; import AddRecipeScreen from '../screens/AddRecipeScreen'; import EditRecipeScreen from '../screens/EditRecipeScreen'; import RecipeDetailScreen from '../screens/RecipeDetailScreen'; import RecipeListScreen from '../screens/RecipeListScreen'; export type RootStackParamList = { RecipeList: undefined; RecipeDetail: {recipeId: string}; AddRecipe: undefined; EditRecipe: {recipeId: string}; }; const Stack = createNativeStackNavigator(); const AppNavigator = () => { return ( <NavigationContainer> <Stack.Navigator initialRouteName="RecipeList"> <Stack.Screen name="RecipeList" component={RecipeListScreen} options={{title: 'Recipes'}} /> <Stack.Screen name="RecipeDetail" component={RecipeDetailScreen} /> <Stack.Screen name="AddRecipe" component={AddRecipeScreen} options={{title: 'Add Recipe'}} /> <Stack.Screen name="EditRecipe" component={EditRecipeScreen} options={{title: 'Edit Recipe'}} /> </Stack.Navigator> </NavigationContainer> ); }; export default AppNavigator;
    Here, we create a stack navigator with our four screens: RecipeListScreen, RecipeDetailScreen, AddRecipeScreen, and EditRecipeScreen. The initial route is set to RecipeList.
  5. Update the App.tsx file as follows:
    tsx
    import React from 'react'; import AppNavigator from './src/navigation/AppNavigator'; const App: React.FC = () => { return <AppNavigator />; }; export default App;
    Here, we're simply adding our AppNavigator to our app. We'll do a little more to this file when we set up Redux.

Step 3: Setting Up Redux

Next, we'll set up Redux for state management in our app. In this step we're only going to make use of Redux in RecipeListScreen.tsx and RecipeDetailScreen.tsx. We'll implement it fully after we've adding some styling.

  1. Create a new file called src/features/recipeSlice.ts with the following code:

    tsx
    import {createSlice, PayloadAction} from '@reduxjs/toolkit'; type Recipe = { id: string; title: string; instructions: string; }; type RecipeState = { recipes: Recipe[]; }; const initialState: RecipeState = { recipes: [ { id: 'recipe-1', title: 'Example Recipe 1', instructions: 'Non ex occaecat ex magna mollit voluptate esse non amet quis culpa enim.', }, { id: 'recipe-2', title: 'Example Recipe 2', instructions: 'Nulla et aliqua ut veniam nostrud sint fugiat.', }, ], }; const recipeSlice = createSlice({ name: 'recipe', initialState, reducers: { addRecipe: (state, action: PayloadAction<Recipe>) => { state.recipes.push(action.payload); }, editRecipe: (state, action: PayloadAction<Recipe>) => { const {id} = action.payload; const index = state.recipes.findIndex(recipe => recipe.id === id); if (index !== -1) { state.recipes[index] = action.payload; } }, deleteRecipe: (state, action: PayloadAction<string>) => { const id = action.payload; const index = state.recipes.findIndex(recipe => recipe.id === id); if (index !== -1) { state.recipes.splice(index, 1); } }, }, }); export const {addRecipe, editRecipe, deleteRecipe, loadRecipes} = recipeSlice.actions; export default recipeSlice.reducer;

    Here, we define the recipe state interface, initial state, and create a recipe slice with actions and reducers for adding, editing, deleting, and loading recipes. We use AsyncStorage from @react-native-async-storage/async-storage to store and retrieve recipe data.

  2. Create another file called src/store/rootReducer.ts with the following code:

    tsx
    import { combineReducers } from 'redux'; import recipeReducer from '../features/recipeSlice'; const rootReducer = combineReducers({ recipe: recipeReducer, }); export type RootState = ReturnType<typeof rootReducer>; export default rootReducer;

    Here, we use the combineReducers function from redux to combine multiple reducers into a single root reducer. In this case, we only have one reducer, recipeReducer from the recipeSlice feature.

  3. Create another file called src/store/store.ts and add the following code:

    tsx
    import AsyncStorage from '@react-native-async-storage/async-storage'; import {configureStore} from '@reduxjs/toolkit'; import {persistReducer, persistStore} from 'redux-persist'; import rootReducer from './rootReducer'; const persistConfig = { key: 'root', storage: AsyncStorage, }; const persistedReducer = persistReducer(persistConfig, rootReducer); export const store = configureStore({ reducer: persistedReducer, middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false, }), }); export const persistor = persistStore(store);

    Here, we import AsyncStorage from @react-native-async-storage/async-storage and configure it as the storage option for redux-persist. We pass AsyncStorage to the storage property in the persistConfig.

    Additionally, we disable the serializableCheck in the getDefaultMiddleware to avoid serialization issues with AsyncStorage.

  4. Update the App.tsx file as follows:

    tsx
    import {configureStore} from '@reduxjs/toolkit'; import React from 'react'; import {Provider} from 'react-redux'; import {PersistGate} from 'redux-persist/integration/react'; import AppNavigator from './src/navigation/AppNavigator'; import {persistor, store} from './src/store/store'; const App: React.FC = () => { return ( <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <AppNavigator /> </Persistor> </Provider> ); }; export default App;

    Here, we wrap the AppNavigator component with the Provider component from react-redux to provide the Redux store to our app. We also import PersistGate from redux-persist/integration/react and wrap the AppNavigator component with it. This ensures that the app waits for the state rehydration from AsyncStorage before rendering.

  5. Update the RecipeListScreen.tsx file as follows:

    tsx
    import React, { useEffect } from 'react'; import { View, Text, Button } from 'react-native'; import { StackNavigationProp } from '@react-navigation/stack'; import { useSelector, useDispatch } from 'react-redux'; import { RootStackParamList } from '../types'; import { RootState } from '../store/rootReducer'; import { loadRecipes } from '../features/recipeSlice'; type RecipeListScreenProps = { navigation: StackNavigationProp<RootStakeParamList, 'RecipeList'>; }; const RecipeListScreen: React.FC<RecipeListScreenProps> = ({ navigation }) => { const recipes = useSelector((state: RootState) => state.recipe.recipes); const dispatch = useDispatch(); useEffect(() => { dispatch(loadRecipes()); }, [dispatch]); return ( <View> <Text>Recipe List Screen</Text> {recipes.map((recipe) => ( <View key={recipe.id} style={{ marginBottom: 10 }}> <Text>{recipe.title}</Text> <Button label="View Details" onPress={() => navigation.navigate('RecipeDetail', { recipeId: recipe.id })} /> </View> ))} <Button label="Add Recipe" onPress={() => navigation.navigate('AddRecipe')} /> </View> ); }; export default RecipeListScreen;

    Now, we import the necessary components to display a list of recipes. We also use the loadRecipes action to load recipes from AsyncStorage when the components mounts. We'll make this look prettier in another step. Pressing on the "View Details" button for a recipe will navigate to the RecipeDetail screen.

  6. Update the RecipeDetailScreen file with the following code:

    tsx
    import React from 'react'; import { View, Text, Button } from 'react-native'; import { StackNavigationProp } from '@react-navigation/stack'; import { useSelector, useDispatch } from 'react-redux'; import { RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../types'; import { RootState } from '../store/store'; import { deleteRecipe } from '../features/recipeSlice'; type RecipeDetailScreenProps = { navigation: StackNavigationProp<RootStackParamList, 'RecipeDetail'>; route: RouteProp<RootStackParamList, 'RecipeDetail'>; }; const RecipeDetailScreen: React.FC<RecipeDetailScreenProps> = ({ navigation, route }) => { const { recipeId } = route.params; const recipes = useSelector((state: RootState) => state.recipe.recipes); const dispatch = useDispatch(); const recipe = recipes.find((recipe) => recipe.id === recipeId); if (!recipe) { return ( <View> <Text>Recipe not found</Text> <Button title="Go Back" onPress={() => navigation.goBack()} /> </View> ); } const handleDeleteRecipe = () => { dispatch(deleteRecipe(recipe.id)); navigation.goBack(); }; return ( <View> <Text>Recipe Detail Screen</Text> <View style={{ marginBottom: 10 }}> <Text>{recipe.title}</Text> <Text>{recipe.instructions}</Text> <Button label="Delete Recipe" onPress={handleDeleteRecipe} /> </View> <Button label="Go Back" onPress={() => navigation.goBack()} /> </View> ); }; export default RecipeDetailScreen;

    Here, we import the components needed to display the basic details of the specified recipe. When we press the "Delete Recipe" button is pressed, we dispatch the deleteRecipe action to remove the recipe from AsyncStorage, then navigate back to the recipe list.

Step 4: Styling the App

Let's add some basic styling to our app using the react-native-ui-lib library. I had never worked with this library prior to writing this tutorial, but I had heard good things and decided to give it a go.

  1. Open the App.tsx file and update it as follows:

    tsx
    import {configureStore} from '@reduxjs/toolkit'; import React from 'react'; import {Typography, View as UILibView} from 'react-native-ui-lib'; import {Provider} from 'react-redux'; import AppNavigator from './src/navigation/AppNavigator'; import rootReducer from './src/store/rootReducer'; const store = configureStore({ reducer: rootReducer, }); Typography.loadTypographies({ h1: {...Typography.text40}, h2: {...Typography.text50}, h3: {...Typography.text70M}, body: Typography.text70, bodySmall: Typography.text80, }); const App: React.FC = () => { return ( <Provider store={store}> <UILibView flex> <AppNavigator /> </UILibView> </Provider> ); }; export default App;

    Here we import the View component from react-native-ui-lib and use it to wrap our app components with flex styling. We've used Typography.loadTypographies from react-native-ui-lib to define some styles we'll use in the next steps.

    Note on Component imports: You'll notice that I imported the View component from react-native-ui-lib as UILibView. I decided to do that in this tutorial to show that there is a clear difference between that and the View component from react-native. This is totally optional, but I have done this throughout the tutorial.

  2. Update the RecipeListScreen.tsx file as follows:

    tsx
    import {NativeStackNavigationProp} from '@react-navigation/native-stack'; import React, {useEffect} from 'react'; import { Button as UILibButton, Text as UILibText, View as UILibView, } from 'react-native-ui-lib'; import {useDispatch, useSelector} from 'react-redux'; import {loadRecipes} from '../features/recipeSlice'; import {RootStackParamList} from '../navigation/AppNavigator'; import {RootState} from '../store/rootReducer'; type RecipeListScreenProps = { navigation: NativeStackNavigationProp<RootStackParamList, 'RecipeList'>; }; const RecipeListScreen: React.FC<RecipeListScreenProps> = ({navigation}) => { const recipes = useSelector((state: RootState) => state.recipe.recipes); const dispatch = useDispatch(); useEffect(() => { dispatch(loadRecipes()); }, [dispatch]); return ( <UILibView flex padding-20> <UILibText h1>Recipe List Screen</UILibText> {recipes.map(recipe => ( <UILibView key={recipe.id} marginB-10> <UILibText h3>{recipe.title}</UILibText> <UILibButton label="View Details" onPress={() => navigation.navigate('RecipeDetail', {recipeId: recipe.id}) } marginT-10 /> </UILibView> ))} <UILibButton label="Add Recipe" onPress={() => navigation.navigate('AddRecipe')} /> </UILibView> ); }; export default RecipeListScreen;

    Here, we update the styling of the recipe list screen using react-native-ui-lib components and utilities instead of the components from react-native.

  3. Update the RecipeDetailScreen.tsx file as follows:

    tsx
    import {RouteProp} from '@react-navigation/native'; import {NativeStackNavigationProp} from '@react-navigation/native-stack'; import React from 'react'; import { Button as UILibButton, Text as UILibText, View as UILibView, } from 'react-native-ui-lib'; import {useDispatch, useSelector} from 'react-redux'; import {deleteRecipe} from '../features/recipeSlice'; import {RootStackParamList} from '../navigation/AppNavigator'; import {RootState} from '../store/rootReducer'; type RecipeDetailScreenProps = { navigation: NativeStackNavigationProp<RootStackParamList, 'RecipeDetail'>; route: RouteProp<RootStackParamList, 'RecipeDetail'>; }; const RecipeDetailScreen: React.FC<RecipeDetailScreenProps> = ({ navigation, route, }) => { const {recipeId} = route.params; const recipes = useSelector((state: RootState) => state.recipe.recipes); const dispatch = useDispatch(); const recipe = recipes.find(recipe => recipe.id === recipeId); if (!recipe) { return ( <UILibView flex padding-20> <UILibText h1>Recipe not found</UILibText> <UILibButton label="Go Back" onPress={() => navigation.goBack()} marginT-20 /> </UILibView> ); } const handleDeleteRecipe = () => { dispatch(deleteRecipe(recipe.id)); navigation.goBack(); }; return ( <UILibView flex padding-20> <UILibText h1>Recipe Detail Screen</UILibText> <UILibView marginT-20> <UILibText h3>{recipe.title}</UILibText> <UILibText>{recipe.instructions}</UILibText> <UILibButton label="Delete Recipe" onPress={handleDeleteRecipe} marginT-20 /> </UILibView> <UILibButton label="Go Back" onPress={() => navigation.goBack()} marginT-20 /> </UILibView> ); }; export default RecipeDetailScreen;

    Here, we update the styling of the recipe detail screen using the react-native-ui-lib components and utilities.

Step 5: Adding Recipes

Now that we have some styling in place, we'll add the functionality to add recipes to our app.

  1. Update the AddRecipeScreen.tsx file with the following code:
    tsx
    import {NativeStackNavigationProp} from '@react-navigation/native-stack'; import React, {useState} from 'react'; import { Button as UILibButton, Text as UILibText, TextField as UILibTextField, View as UILibView, } from 'react-native-ui-lib'; import {useDispatch} from 'react-redux'; import {v4 as uuidv4} from 'uuid'; import {addRecipe} from '../features/recipeSlice'; import {RootStackParamList} from '../navigation/AppNavigator'; type AddRecipeScreenProps = { navigation: NativeStackNavigationProp<RootStackParamList, 'AddRecipe'>; }; const AddRecipeScreen: React.FC<AddRecipeScreenProps> = ({navigation}) => { const [title, setTitle] = useState(''); const [instructions, setInstructions] = useState(''); const dispatch = useDispatch(); const handleAddRecipe = () => { if (title && instructions) { const newRecipe = { id: uuidv4(), title, instructions, }; dispatch(addRecipe(newRecipe)); navigation.goBack(); } }; return ( <UILibView flex padding-20> <UILibText h1>Add Recipe</UILibText> <UILibTextField placeholder="Recipe Title" floatingPlaceholder floatOnFocus value={title} onChangeText={text => setTitle(text)} marginT-20 /> <UILibTextField placeholder="Recipe Instructions" floatingPlaceholder floatOnFocus value={instructions} onChangeText={text => setInstructions(text)} marginT-20 /> <UILibButton label="Add Recipe" onPress={handleAddRecipe} marginT-20 /> <UILibButton label="Cancel" onPress={() => navigation.goBack()} marginT-10 /> </UILibView> ); }; export default AddRecipeScreen;
    Here, we've update the screen to allow the user to input a new recipe title and instructions. The handleAddRecipe function uses the uuidv4 function from the uuid package. This function generates a unique identifier using the UUID (Universally Unique Identifier) version 4 algorithm. We then dispatch the addRecipe action with the generated ID and the entered data before we navigate back to the recipe list.

Step 6: Editing Recipes

In this step, we'll add the functionality to edit recipes in our app.

  1. Update the EditRecipeScreen.tsx file with the following code:

    tsx
    import React, { useState, useEffect } from 'react'; import { StackNavigationProp } from '@react-navigation/stack'; import { useSelector, useDispatch } from 'react-redux'; import { RootStackParamList } from '../types'; import { RootState } from '../store/store'; import { editRecipe } from '../features/recipeSlice'; import { View as UILibView, TextInput as UILibTextInput, Button as UILibButton } from 'react-native-ui-lib'; type EditRecipeScreenProps = { navigation: StackNavigationProp<RootStackParamList, 'EditRecipe'>; route: RouteProp<RootStackParamList, 'EditRecipe'>; }; const EditRecipeScreen: React.FC<EditRecipeScreenProps> = ({ navigation, route }) => { const { recipeId } = route.params; const recipes = useSelector((state: RootState) => state.recipe.recipes); const dispatch = useDispatch(); const recipe = recipes.find((recipe) => recipe.id === recipeId); const [title, setTitle] = useState(recipe?.title || ''); const [instructions, setInstructions] = useState(recipe?.instructions || ''); useEffect(() => { if (recipe) { setTitle(recipe.title); setInstructions(recipe.instructions); } }, [recipe]); const handleEditRecipe = () => { if (recipe && title && instructions) { dispatch(editRecipe({ id: recipe.id, title, instructions })); navigation.goBack(); } }; if (!recipe) { return ( <UILibView flex padding-20> <UILibText h1>Recipe not found</UILibText> <UILibButton label="Go Back" onPress={() => navigation.goBack()} marginT-20 /> </UILibView> ); } return ( <UILibView flex padding-20> <UILibText h1>Edit Recipe</UILibText> <UILibTextInput placeholder="Recipe Title" value={title} onChangeText={(text) => setTitle(text)} marginT-20 /> <UILibTextInput placeholder="Recipe Instructions" value={instructions} onChangeText={(text) => setInstructions(text)} marginT-20 /> <UILibButton label="Save Changes" onPress={handleEditRecipe} marginT-20 /> <UILibButton label="Cancel" onPress={() => navigation.goBack()} marginT-10 link /> </UILibView> ); }; export default EditRecipeScreen;

    Here, we've update the EditRecipeScreen component to allow the user to edit the title and instructions of a recipe. The component now fetches the recipe data based on the recipeId parameter and pre-fills the input fields. The handleEditRecipe function dispatches the editRecipe action with the updated data and navigates back to the recipe detail screen.

  2. Update the RecipeDetailScreen.tsx file to navigate to the EditRecipe screen:

    tsx
    import React from 'react'; import { View, Text } from 'react-native'; import { StackNavigationProp } from '@react-navigation/stack'; import { useSelector, useDispatch } from 'react-redux'; import { RootStackParamList } from '../types'; import { RootState } from '../store/store'; import { deleteRecipe } from '../features/recipeSlice'; import { View as UILibView, Text as UILibText, Button as UILibButton } from 'react-native-ui-lib'; type RecipeDetailScreenProps = { navigation: StackNavigationProp<RootStackParamList, 'RecipeDetail'>; route: RouteProp<RootStackParamList, 'RecipeDetail'>; }; const RecipeDetailScreen: React.FC<RecipeDetailScreenProps> = ({ navigation, route }) => { const { recipeId } = route.params; const recipes = useSelector((state: RootState) => state.recipe.recipes); const dispatch = useDispatch(); const recipe = recipes.find((recipe) => recipe.id === recipeId); const handleDeleteRecipe = () => { dispatch(deleteRecipe(recipeId)); navigation.goBack(); }; if (!recipe) { return ( <UILibView flex padding-20> <UILibText h1>Recipe not found</UILibText> <UILibButton label="Go Back" onPress={() => navigation.goBack()} marginT-20 /> </UILibView> ); } return ( <UILibView flex padding-20> <UILibText h1>Recipe Detail Screen</UILibText> <UILibView marginT-20> <UILibText h3>{recipe.title}</UILibText> <UILibText>{recipe.instructions}</UILibText> <UILibButton label="Edit Recipe" onPress={() => navigation.navigate('EditRecipe', { recipeId })} marginT-10 /> <UILibButton label="Delete Recipe" onPress={handleDeleteRecipe} marginT-10 /> </UILibView> <UILibButton label="Go Back" onPress={() => navigation.goBack()} marginT-20 /> </UILibView> ); }; export default RecipeDetailScreen;

    Here, we've updated the "Edit Recipe" button to navigate to the EditRecipe screen when pressed, passing the recipeId as a parameter.

    Now, when you run the app and go to the recipe detail screen, you'll see the "Edit Recipe" button. Tapping on it will take you to the EditRecipe screen where you can modify the recipe's title and description. Pressing the "Save Changes" button will update the recipe and navigate back to the recipe detail screen.

Step 7: Testing the App

Congratulations! You have successfully built a Recipe App using React Native, Redux, and TypeScript with styling using the react-native-ui-lib library. Now, let's test the app.

  1. Start the Metro bundler by running the following command in the project directory:
    shell
    npx react-native start
  2. Run the app on an Android or iOS emulator using the following command:
    shell
    npx react-native run-android # or npx react-native run-ios
    Make sure you have the Android emulator or iOS simulator running beforehand.
  3. You should see the Recipe App running on the emulator/simulator. Test the functionality by adding, editing, and deleting recipes.

That's all, folks!

Congratulations! You have successfully built a Recipe App using React Native, Redux, TypeScript, and the react-native-ui-lib library for styling. Throughout this tutorial, you learned how to set up navigation, manage application state with Redux, style your app using react-native-ui-lib components and utilities, and implement features like adding and editing recipes. There is a lot more that can be done to style this app. Look into adding a nicer title bar or header for the app or try different colour schemes.

Feel free to explore and enhance the app further by adding more features like favouriting recipes, adding images, or implementing search functionality. The possibilities are endless!

You can find the complete source code for this tutorial on GitHub.

Thank you for following along with this tutorial. Happy coding!