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:
- Node.js
- npm or Yarn
- React Native CLI
- Android SDK or Xcode (for iOS development)
Step 1: Setting Up the Project
Let's start by setting up a new React Native project.
- Open your terminal and navigate to the directory where you want to create the project.
- Run the following command to create a new React Native project:
shell
This command creates a new React Native project using the TypeScript template.npx react-native init RecipeApp --template react-native-template-typescript
- Navigate to the project directory:
shell
cd RecipeApp
- Install the required dependencies by running the following command:
shell
This command installs the necessary packages for Redux, navigation, screen components, functionality, and the UI library we'll be using later.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
- Install additional dependencies required by the
react-native-ui-lib
library:shell
This command installs the necessary iOS dependencies using CocoaPods.npx pod-install
- Update the
babel.config.js
file with the following code:javascriptmodule.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.
- Create a new file called
src/screens/AppNavigator.tsx
. We'll add more here soon, but for now add the following code:typescript
This sets up the the types to be used in the following steps.export type RootStackParamList = { RecipeList: undefined; RecipeDetail: {recipeId: string}; AddRecipe: undefined; EditRecipe: {recipeId: string}; };
- Create a new file called
src/screens/RecipeListScreen.tsx
and add the following code:tsx
At this point, the screen displays the text "Recipe List Screen" along with a button to navigate to theimport 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;
AddRecipe
screen. - Using the above as a guideline create additional files for
RecipeDetailScreen
,AddRecipeScreen
, andEditRecipeScreen
. Pressing the buttons on these screens can go to whatever screen you like. Remember, this content is only temporary. - Once all your screens have been created, go back to
AppNavigator.tsx
and update it with the following code:tsx
Here, we create a stack navigator with our four screens: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;
RecipeListScreen
,RecipeDetailScreen
,AddRecipeScreen
, andEditRecipeScreen
. The initial route is set toRecipeList
. - Update the
App.tsx
file as follows:tsx
Here, we're simply adding ourimport React from 'react'; import AppNavigator from './src/navigation/AppNavigator'; const App: React.FC = () => { return <AppNavigator />; }; export default App;
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.
-
Create a new file called
src/features/recipeSlice.ts
with the following code:tsximport {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. -
Create another file called
src/store/rootReducer.ts
with the following code:tsximport { 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 fromredux
to combine multiple reducers into a single root reducer. In this case, we only have one reducer,recipeReducer
from therecipeSlice
feature. -
Create another file called
src/store/store.ts
and add the following code:tsximport 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 forredux-persist
. We passAsyncStorage
to thestorage
property in thepersistConfig
.Additionally, we disable the
serializableCheck
in thegetDefaultMiddleware
to avoid serialization issues with AsyncStorage. -
Update the
App.tsx
file as follows:tsximport {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 theProvider
component fromreact-redux
to provide the Redux store to our app. We also importPersistGate
fromredux-persist/integration/react
and wrap theAppNavigator
component with it. This ensures that the app waits for the state rehydration from AsyncStorage before rendering. -
Update the
RecipeListScreen.tsx
file as follows:tsximport 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 fromAsyncStorage
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 theRecipeDetail
screen. -
Update the
RecipeDetailScreen
file with the following code:tsximport 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 fromAsyncStorage
, 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.
-
Open the
App.tsx
file and update it as follows:tsximport {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 fromreact-native-ui-lib
and use it to wrap our app components with flex styling. We've usedTypography.loadTypographies
fromreact-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 fromreact-native-ui-lib
asUILibView
. I decided to do that in this tutorial to show that there is a clear difference between that and theView
component fromreact-native
. This is totally optional, but I have done this throughout the tutorial. -
Update the
RecipeListScreen.tsx
file as follows:tsximport {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 fromreact-native
. -
Update the
RecipeDetailScreen.tsx
file as follows:tsximport {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.
- Update the
AddRecipeScreen.tsx
file with the following code:tsx
Here, we've update the screen to allow the user to input a new recipe title and instructions. Theimport {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;
handleAddRecipe
function uses theuuidv4
function from theuuid
package. This function generates a unique identifier using the UUID (Universally Unique Identifier) version 4 algorithm. We then dispatch theaddRecipe
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.
-
Update the
EditRecipeScreen.tsx
file with the following code:tsximport 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 therecipeId
parameter and pre-fills the input fields. ThehandleEditRecipe
function dispatches theeditRecipe
action with the updated data and navigates back to the recipe detail screen. -
Update the
RecipeDetailScreen.tsx
file to navigate to theEditRecipe
screen:tsximport 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 therecipeId
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.
- Start the Metro bundler by running the following command in the project directory:
shell
npx react-native start
- Run the app on an Android or iOS emulator using the following command:
shell
Make sure you have the Android emulator or iOS simulator running beforehand.npx react-native run-android # or npx react-native run-ios
- 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!