How To Set Up React Redux For Production (With TypeScript)
In this video you're going to learn two setups for configuring Redux in production with TypeScript.
But it's going to be controversial because you're going to see one with sagas, which is the legacy way, and one with RTK Query.
RTK Query is the officially recommended approach, but it's important to understand both. The traditional method contains many advanced and often forgotten patterns that are valuable to know. Mastering both will prepare you to quickly onboard and contribute to any Redux project.
This video will also share some highly advanced composition techniques using functional programming and higher-order components that I've picked up while working on dozens of Redux projects as the CTO of ReactSquad.
And even though you can watch this video on its own if you're already familiar with Redux, it is actually part three of a five part series on Redux. So if you're new to Redux or Sagas, go watch part one and two first.
App Overview
Note to Cheta: Once you've finished the app, record all functionality, so that the editors can show it here while I explain it.
The app you're building in this tutorial will have three pages:
- A login screen where users can enter their email and password.
- A dashboard that fetches user data, and allows the user to log out.
- A posts page that displays all posts.
You'll organize your code by feature and split the app into five features. Remember, in Redux, a feature frequently corresponds to a state slice.
- App Loading: This feature handles the code that runs every time the app loads or the user refreshes the page. It includes general logic that's always triggered when the app starts, such as when the user first visits your app or manually refreshes the browser window.
- Dashboard: This feature manages the UI components for the dashboard. If there's any logic specific to the dashboard, it would go here too.
- User Authentication: This feature handles both the logic and UI for logging users in and out.
- User Profiles: This feature includes the logic for managing user profiles.
- Posts: This feature allows you to create and list posts.
Quick note on grouping by feature: Although the dashboard will display user profiles, you're separating the dashboard UI from the user profiles logic. That's because in a real-world app, the dashboard might display more than just user profiles - like usage stats, stock performances, or notifications. Plus, user profiles might appear in places beyond the dashboard, like in the settings where admins manage permissions.
TODO: here zwei flows wie Redux mit dem Server redet (entweder React first dann dispatch, oder middleware only)
Project Setup
Now, let's get started.
TODO: trust me bro
If you want to code along, which I highly recommend if you want to retain the most knowledge, create a new Next.js project.
$ npx create-next-app@latest
✔ **What is your project named?** redux-for-production
✔ **Would you like to use** **TypeScript****?** … No / Yes
Yes
✔ **Would you like to use** **ESLint****?** … No / Yes
Yes
✔ **Would you like to use** **Tailwind CSS****?** … No / Yes
Yes
✔ **Would you like to use** **`src/` directory****?** … No / Yes
Yes
✔ **Would you like to use** **App Router****? (recommended)** … No / Yes
Yes
✔ **Would you like to customize the default** **import alias** **(@/*)?** … No / Yes
No
Then install Redux Toolkit, React Redux, Ramda, Redux Saga, and the axios package ...
npm install @reduxjs/toolkit react-redux ramda redux-saga axios
... along with the types for Ramda.
npm install --save-dev @types/ramda
Notice that you did NOT need to install the Redux package separately. This is because Redux Toolkit exports all the core functions that Redux normally provides, such as createStore
, combineReducers
, and applyMiddleware
.
Initialize Shadcn in the project.
$ npx shadcn@latest init
✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Slate
✔ Would you like to use CSS variables for colors? … no / yes
✔ Writing components.json...
✔ Initializing project...
✔ Installing dependencies...
Success! Project initialization completed. You may now add components.
You're going to use it to make the pages look somewhat nice.
Note to Cheta: Show this, too 👇
Since you'll be grouping files by feature, configure your Tailwind config to detect content in your features/
folder by adding './src/features/**/*.{js,ts,jsx,tsx,mdx}',
to the content
key in your Tailwind config.
Then install the components required for the authentication form, which you'll use later.
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add input
npx shadcn@latest add label
Now, create a custom component for a spinner in src/components/spinner.tsx
.
import { SVGProps } from 'react';
export const Spinner = (props: SVGProps<SVGSVGElement>) => (
<svg
aria-hidden="true"
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
Reminder: Redux Data Flow vs. Redux Code Flow
Explain how data flows in a Redux app. And explain where which component of Redux lives. Explain decision process why what was written when.
Redux Toolkit
Earlier, you installed Redux Toolkit (RTK). If you're new to Redux and following this series, this is your first look at Redux Toolkit.
Redux Toolkit is the official, opinionated, batteries-included toolset for Redux development. It includes utilities that help you automate common Redux patterns and handle immutability.
Redux Toolkit is written in TypeScript and makes it easy to set up and type Redux correctly, which can be difficult when done manually.
createAction
Using Redux Toolkit, there are two ways to create action creators: explicitly and implicitly.
You will see how to do it implicitly later in this tutorial when you use the createSlice
function. createSlice
usually couples action to a specific slice.
To create an action creator explicitly, you use the createAction
function. This allows you to define actions that are not related to a specific slice, but instead are shared across multiple slices.
Create a file in src/redux/clear.ts
and define a clear
action creator using createAction
.
import { createAction } from '@reduxjs/toolkit';
export const clear = createAction('all/clear');
You pass in the type of the action as the argument. You will use the clear
action creator in your reducers to reset them to the initial state, after your user logs out. This is an example for a "global" action that will be used in multiple reducers.
Calling clear
returns an object with the given type property.
clear(); // { type: 'all/clear' }
Why would you use createAction
over manually defining the action creator? Because createAction
has full TypeScript support. It can infer the type of your payload dynamically. You can also manually type the payload and use a literal type of the action's type.
import { createAction } from '@reduxjs/toolkit';
// Hardcode the type of the payload (dynamic).
const setAge = createAction<number>('setAge');
// Use a literal type for the action's type (manual).
const setName = createAction<string, 'setName'>('setName');
// Calling the actions with specific values
const ageAction = setAge(30);
const nameAction = setName("John");
// Logging the actions
console.log(ageAction);
console.log(nameAction);
In the first action, you dynamically infer the type of the action type. In the second action, you manually define the type of the action type as the constant 'setName'
.
When you run this code, it logs out actions of the shape that you're used to.
{ type: "setAge", payload: 30 }
{ type: "setName", payload: "John" }
It's important to understand that createAction
also sets a property type
of the function object on the action creator.
const login = ({ token }) => ({ type: login.type, payload: { token } });
console.log(login.type); // undefined
login.type = 'login';
console.log(login.type); // `${name}/login`
Functions in JavaScript are objects, so you can add properties to them just like any other object. createAction
sets a type
property on the action creator. This allows you to access login.type
directly.
So this is the same as:
const login = createAction<{ token: string }>('login');
Next, create your root reducer in src/redux/root-reducer.ts
.
import { combineReducers } from '@reduxjs/toolkit';
export const rootReducer = combineReducers({
'temp-not-used-so-app-does-not-crash': state => state,
});
You can import combineReducers
directly from Redux Toolkit.
Then create your Redux store in src/redux/store.ts
next.
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from './root-reducer';
export const makeStore = () => {
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware => getDefaultMiddleware({ thunk: false }),
});
return store;
};
// Infer the type of makeStore's store.
export type AppStore = ReturnType<typeof makeStore>;
// Infer the `RootState` and `AppDispatch` types from the store itself.
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
Import configureStore
and use it inside your makeStore
function. Then export types for your AppStore
, your RootState
and your AppDispatch
. If you watched the first video of this series, you should understand what's going on here. The only new thing is the TypeScript types.
type MakeStore = typeof makeStore;
// () => { getStore: () => RootState, dispatch: () => {} /* ... */}
type AppStore = ReturnType<MakeStore>;
// { getStore: () => {}, dispatch: () => {} /* ... */ }
type GetState = AppStore['getState'];
// () => RootState
type RootState = ReturnType<GetState>;
TODO: explain how configureStore
does the heavy lifting for the types.
Create your Redux hooks in src/redux/hooks.ts
.
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { AppDispatch, AppStore, RootState } from './store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`.
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<AppStore>();
Make your custom hooks TypeScript compatible by importing the types you just defined from your store and passing them to the respective hooks.
const count = useAppSelector(state => state.count);
TODO: explain why each hook is passed the types.
Next, you'll also need a store provider, so create src/redux/store-provider.ts
.
'use client';
import { useRef } from 'react';
import { Provider } from 'react-redux';
import { AppStore, makeStore } from './store';
export default function StoreProvider({
children,
}: {
children: React.ReactNode;
}) {
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
// Creates the store instance the first time this renders.
const store = makeStore();
storeRef.current = store;
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
Use the 'use client'
directive to ensure that the provider only renders on the client, and use useRef
to make sure you only create only one instance of the store. Pass your store to React Redux's provider and wrap it around the children of your store provider component.
Now wrap your StoreProvider
around your root layout.
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import StoreProvider from '@/redux/store-provider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Jan Hesters Production Redux Tutorial',
description: 'Part three of five to master Redux.',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<StoreProvider>
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
</StoreProvider>
);
}
This makes the Redux context available in every client-side component throughout your entire app.
Your app is now ready for you to write your first reducer.
TODO: show graphic from first script and "check off" store.
createSlice
Redux Toolkit provides a function called createSlice
that lets you define reducers and actions together. Before you dive into using it, here's the reducer you're going to create, written manually.
import { not, prop } from 'ramda';
// action creator
export const finishedAppLoading = () => ({ type: 'FINISHED_APP_LOADING' });
// reducer
export const slice = 'appLoading';
const initialState = { appIsLoading: true };
export const reducer = (state = initialState, { type } = {}) => {
switch (type) {
case finishedAppLoading().type: {
return { ...state, appIsLoading: false };
}
default: {
return state;
}
}
};
// selectors
const selectAppLoadingSlice = prop(slice);
export const selectAppIsLoading = pipe(
selectAppLoadingSlice,
prop('appIsLoading'),
);
export const selectAppFinishedLoading = pipe(selectAppIsLoading, not);
// not(true); // false ℹ️
This Redux slice manages the loading state of your application.
The slice includes an action creator, finishedAppLoading
, which dispatches an action to change the appIsLoading
state to false
when called. This signifies that the app has finished loading.
The selector selectAppIsLoading
is defined using Ramda to efficiently retrieve the current loading state. selectAppFinishedLoading
does the same but as a negation, using the Ramda not
function. This allows the rest of the application to react to changes in the loading state based on these derived values and enables descriptive representations of both loading states.
If this is too much for you, you might want to rewatch the first video of this series as a refresher.
Now, use createSlice
to create our app loading reducer in TypeScript.
Note to Cheta: Start with an empty object, then define the parameters inside createSlice
one after another. After each parameter, add the destructured property to the slice object returned.
So, use createSlice
with an empty object and destructure the reducer
. Then define name
in the object argument for createSlice
, then destructure it. Define the initialState
. Reveal the reducers
with finishedAppLoading
, then destructure the actions, and so on.
import { createSlice } from '@reduxjs/toolkit';
import { not, pipe, prop } from 'ramda';
const initialState = { appIsLoading: true };
export const {
actions: { finishedAppLoading },
name,
reducer,
selectors: { selectAppIsLoading },
} = createSlice({
name: 'appLoading',
initialState,
reducers: {
finishedAppLoading: state => {
state.appIsLoading = false;
},
},
selectors: {
selectAppIsLoading: prop<'appIsLoading'>('appIsLoading'),
},
});
export const selectAppFinishedLoading = pipe(selectAppIsLoading, not);
First, import createSlice
from Redux Toolkit and not
, pipe
, and prop
from Ramda.
Define the initialState
, which is just an object holding a boolean that tracks whether the app is loading or not.
createSlice
returns a slice object with many properties and methods. For a full API reference, you can check out the docs. This tutorial will focus on the most commonly used properties, and you can refer to the documentation if you encounter others.
To understand those properties, you need to understand the parameter of createSlice
. createSlice
takes in only one object with 4 keys.
The name
property holds the name of the slice.
The initialState
sets the starting state.
The reducers
key is an object where each key is a function. These functions determine how the state changes when specific actions are dispatched to the store. Behind the scenes, they generate the switch statement with the action handlers. Each key becomes an action creator that you can destructure from the actions
property on the slice object returned by createSlice
. This is what was meant earlier when you learned that createSlice
implicitly creates action creators. In this case, when the action created by the finishedAppLoading
action creator is dispatched, it sets appIsLoading
to false
.
You might be wondering right now, "Wait, am I mutating the state by changing the appIsLoading
property on the state
object directly? I thought reducers were supposed to change state immutably?" With createSlice
, you can directly manipulate the slice's state because it uses a library called Immer under the hood. Immer tracks every attempt to change the state. It then safely replays those mutations using their immutable equivalents to create an updated result.
The last key, selectors
, is an object where each key is a selector that takes in this specific slice's state instead of the root state. This simplifies defining selectors. You can then destructure these selectors from the slice object. If you want to compose selectors, like selectAppFinishedLoading
, you have to do that outside of createSlice
.
There can be two more keys on that object, reducerPath
and extraReducers
, which you're going to see later in this tutorial.
Now you can hook up the app loading slice in your root reducer.
import { combineReducers } from '@reduxjs/toolkit';
import {
name as appLoadingSliceName,
reducer as appLoadingReducer,
} from '@/features/app-loading/app-loading-reducer';
export const rootReducer = combineReducers({
[appLoadingSliceName]: appLoadingReducer,
});
In the app loading reducer, finishedAppLoading
does NOT take a payload. So how do you handle actions that do have payloads?
To see that, create the reducer for the user authentication feature.
Note to Cheta: Reveal the keys of the argument for createSlice
- especially each action (login
, loginSucceeded
, stopAuthenticating
) - one after another.
For the return value, you can start as an empty object and then destructure everything in one go after the arguments.
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { prop } from 'ramda';
import { clear } from '@/redux/clear';
const initialState = { isAuthenticating: false, token: '' };
export const {
actions: { login, loginSucceeded, stopAuthenticating },
name,
reducer,
selectors: { selectIsAuthenticating, selectAuthenticationToken },
} = createSlice({
name: 'userAuthentication',
initialState,
reducers: {
login: (
state,
{ payload }: PayloadAction<{ email: string; password: string }>,
) => {
state.isAuthenticating = true;
},
loginSucceeded: (state, { payload }: PayloadAction<{ token: string }>) => {
state.token = payload.token;
},
stopAuthenticating: state => {
state.isAuthenticating = false;
},
},
extraReducers: builder => {
builder.addCase(clear, () => initialState);
}, // clear().type; // 'all/clear'
selectors: {
selectIsAuthenticating: prop<'isAuthenticating'>('isAuthenticating'),
selectAuthenticationToken: prop<'token'>('token'),
},
});
Import the PayloadAction
type and the clear
action.
Define the initialState
and give the slice a name.
Create the login
action using the PayLoadAction
type. This tells TypeScript and Redux Toolkit that the login
action for this slice includes a payload with an email and a password. While this reducer doesn't use the payload directly, you'll utilize it later when handling asynchronous logic. But the action sets isAuthenticating
to true
.
The second action, loginSucceeded
, has a payload with a token, which you store in your state. It's common practice in Redux to save the auth token in reducers. This is safe because if an attacker gains access to your user's browser and can run JavaScript on your page, you're already in serious trouble.
The third action, stopAuthenticating
, simply sets the isAuthenticating
boolean to false
. This will be dispatched by a saga once it finishes authenticating. You will see this later.
When a user logs out, you want to reset all relevant reducers to their initial state. That's where the clear
action you defined earlier comes in. You can use extraReducers
to allow the slice to handle actions defined outside its scope. reducers
does not allow you do this. extraReducers
is a function that takes a builder
as the first parameter. It works like a switch statement but with better TypeScript support, as it can infer the action type from the provided action creator.
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
const initialState = { isLoading: false, error: null };
const fetchData = {
type: 'fetchData',
payload: {}
};
const setError = {
type: 'setError',
payload: 'An error occurred'
};
const dataSlice = createSlice({
name: 'data',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
// `addCase` is used to handle a specific action type.
// Here, it responds to 'fetchData' by setting isLoading to true.
.addCase(fetchData.type, (state, action: PayloadAction<{}>) => {
state.isLoading = true;
})
// `addMatcher` allows for custom logic to determine if the
// reducer should handle the action.
// The first parameter is a function that takes an action and returns a
// boolean, which determines if the action should be handled by the
// reducer.
// The second parameter is a callback function that receives the current
// state and the payload, describing how the state should be handled.
// This example checks if the action type is 'setError' and
// then processes the action.
.addMatcher(
(action): action is PayloadAction<string> => action.type === 'setError',
(state, action) => {
state.error = action.payload;
}
)
// `addDefaultCase` provides a fallback handler for actions
// not specifically addressed.
.addDefaultCase((state, action) => {
console.log('No specific handler for action:', action.type);
});
}
});
The builder
offers three functions, with the most important being addCase
. addCase
takes two parameters: the first is the action to respond to, and the second is a callback function that receives the current state and the payload, describing how the state should be updated. There's also addMatcher
, which allows you to define a function to decide whether to handle the action, and addDefaultCase
, which is executed if no other case reducer was executed for a given action.
In the user authentication reducer, the builder.addCase
method specifically addresses the clear
action, instructing the slice to reset its state to the initial configuration.
Finally, define the selectors to check whether the user is authenticating and to return the token from the slice.
Now, wire up the slice to your root reducer.
import { combineReducers } from '@reduxjs/toolkit';
import {
name as appLoadingSliceName,
reducer as appLoadingReducer,
} from '@/features/app-loading/app-loading-reducer';
import {
name as userAuthenticationSliceName,
reducer as userAuthenticationReducer,
} from '@/features/user-authentication/user-authentication-reducer';
export const rootReducer = combineReducers({
[appLoadingSliceName]: appLoadingReducer,
[userAuthenticationSliceName]: userAuthenticationReducer,
});
createEntitiyAdapter
There is one more slice you need to create to manage the user profiles.
Create a type for your user profiles in src/features/user-profiles/user-profiles-types.ts
.
export type UserProfile = {
/**
* Email of the user.
*/
email: string;
/**
* The users ID.
*/
id: string;
/**
* Name of the user.
*/
name: string;
};
You're going to use Redux Toolkit's createEntityAdapter
to manage the user profiles. It provides a standardized way to store normalized entity data in a slice of the Redux state. It offers built-in methods for efficiently performing CRUD operations on normalized data.
Note to Cheta: Same here as with the last reducer. Make sure you show the destructuring before defining the selectors below the createSlice
.
import type { PayloadAction } from '@reduxjs/toolkit';
import {
createEntityAdapter,
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import { complement, isNil, prop } from 'ramda';
import { clear } from '@/redux/clear';
import { RootState } from '@/redux/store';
import { UserProfile } from './user-profiles-types';
const userProfilesAdapter = createEntityAdapter<UserProfile>({
sortComparer: (a, b) => a.email.localeCompare(b.email),
});
const initialState = userProfilesAdapter.getInitialState({
currentUsersId: '',
isLoading: true,
});
export const {
actions: { currentUserProfileFetched, usersListFetched },
name,
reducer,
selectSlice: selectUserProfileSlice,
selectors: { selectCurrentUsersId, selectUserProfilesAreLoading },
} = createSlice({
name: 'userProfiles',
initialState,
reducers: {
currentUserProfileFetched: (
state,
{ payload }: PayloadAction<UserProfile>,
) => {
state.currentUsersId = payload.id;
userProfilesAdapter.upsertOne(state, payload);
},
usersListFetched: (state, { payload }: PayloadAction<UserProfile[]>) => {
userProfilesAdapter.setMany(state, payload);
state.isLoading = false;
},
},
extraReducers: builder => {
builder.addCase(clear, () => initialState);
},
selectors: {
selectCurrentUsersId: prop<'currentUsersId'>('currentUsersId'),
selectUserProfilesAreLoading: prop<'isLoading'>('isLoading'),
},
});
const userProfileSelectors = userProfilesAdapter.getSelectors(
selectUserProfileSlice,
);
const selectCurrentUsersProfile = (state: RootState) =>
userProfileSelectors.selectById(state, selectCurrentUsersId(state));
export const selectCurrentUsersName = (state: RootState) =>
selectCurrentUsersProfile(state)?.name || 'Anonymous';
export const selectIsAuthenticated = createSelector(
selectCurrentUsersProfile,
complement(isNil),
);
export const selectUsersList = userProfileSelectors.selectAll;
Import createEntityAdapter
along with other Redux Toolkit and Ramda helpers.
Then create a userProfilesAdapter
using createEntityAdapter
. You pass in the UserProfile
type to let TypeScript know, what shape those profiles are going to take. You're going to learn about sortComparer
in a moment.
Call the getInitialState
method on the userProfilesAdapter
, along with additional properties that your user profile state should have, to create the initial state.
A populated version might look like this:
{
"ids": ["2", "1", "3"],
"entities": {
"1": { "id": "1", "name": "Bob", "email": "bob@example.com" },
"2": { "id": "2", "name": "Alice", "email": "alice@example.com" },
"3": { "id": "3", "name": "Charlie", "email": "charlie@example.com" }
},
"currentUsersId": "2",
"isLoading": false
}
The state created by createEntityAdapter
, combined with your custom initial state, results in a normalized structure with following properties:
ids
: An array of entity IDs, providing an ordered list for retrieving entities from theentities
object.entities
: An object where each key is an entity ID and each value is the entity itself. This allows quick access to any entity by its ID.currentUsersId
: A custom property that tracks the current user's ID, starting as an empty string in yourinitialState
.isLoading
: Another custom property that shows whether data is loading, set totrue
in yourinitialState
.
The sortComparer
option in createEntityAdapter
specifies a comparison function used to maintain the order of entity IDs in the ids
array based on the entities themselves. This function determines how entities should be sorted relative to one another whenever entities are added or updated in the state. In your case, the user profiles are stored in alphabetical order by email.
Now, use createSlice
to create your reducer. It should handle two actions.
The first action handles the case when the current user's profile has been fetched successfully. When that happens, you need to set that ID as the currentUsersId
. You also need to add that profile to the entities
object containing all profiles if it doesn't exist yet.
The adapter created by createEntityAdapter
exposes a collection of 12 CRUD functions that execute the most common CRUD operations on the ids
array and the entities
object.
The userProfilesAdapter.upsertOne
method does exactly what your userProfileFetched
action should do. It either updates a profile if it exists, or adds it to all profiles, updating both the ids
array and the entities
object. Remember, it will automatically insert the id of the entity at the correct position in the ids
array based on the sortComparer
.
The second action adds users to the state when they've been fetched. It uses the userProfilesAdapter.setMany
method to add an array of profiles to the existing ones, again implicitly updating both ids
and entities
. Additionally, it sets isLoading
to false
.
Hook up the external clear
action so the user profile slice will reset to its initial state later when you log out.
Next, you add two selectors to grab the current user's id and the loading state.
When you destructure the slice object, you'll destructure a new property selectSlice
. This property is the selector that returns the slice from the root state. It is the selector that you needed to define manually in the first video of this series.
const selectUserProfileSlice = state => state[slice];
Therefore, both selectCurrentUsersId
and selectUserProfilesAreLoading
are composed from selectUserProfileSlice
implicitly through the selectors
property in createSlice
. The other selectors that you'll define now will be composed using it explicitly.
userProfileSelectors
is created using getSelectors
from userProfilesAdapter
, receiving selectUserProfileSlice
as an argument to point the selectors to the correct slice of the state. Its a collection of five common selectors. Here are pseudo-implementations for them:
// Returns the array of IDs from the state, which represent the
// order of the entities.
const selectIds = state => state.userProfiles.ids;
// Returns the lookup table (the normalized object) of entities,
// where each key is an ID, and the corresponding value is the entity.
const selectEntities = state => state.userProfiles.entities;
// Maps over the state.ids array to return an array of entities
// in the same order as the ids-array.
const selectAll = state =>
state.userProfiles.ids.map(id => state.userProfiles.entities[id]);
// Returns the total number of entities stored in the state by
// checking the length of the ids array.
const selectTotal = state => state.userProfiles.ids.length;
// Given the state and an entity ID, returns the corresponding
// entity or undefined if no such entity exists.
const selectById = (state, id) => state.userProfiles.entities[id];
You use selectById
to create selectCurrentUsersProfile
, which returns the current user profile by ID.
Using selectCurrentUsersProfile
, you compose another selector that returns the current user's name.
All selectors created by getSelectors
are memoized. You can also memoize specific selectors using a new helper function createSelector
.
createSelector
Redux Toolkit provides a helper function for selectors called createSelector
. This function is re-exported from Reselect, which is a library for creating memoized selector functions. It is useful for any case that is not handled by the 5 selectors provided by getSelectors
.
createSelector
accepts one or more "input selectors" and a callback function that combines their results and returns a memoized selector. If you have more than one input selector, you can pass them as an array.
You could use it to create a selector that memoizes access to user profiles.
import { createSelector } from '@reduxjs/toolkit';
const selectUserProfileSlice = state => state[slice];
export const selectCurrentUsersId = state => state[slice].currentUserId;
const selectCurrentUsersProfile = createSelector(
[selectUserProfileSlice, selectCurrentUsersId],
(userProfiles, currentUserId) => userProfiles.entities[currentUserId],
);
This version of selectCurrentUsersProfile
is memoized. That means, when it is called with a set of inputs (in this case, userProfiles
and currentUserId
), it checks if it has previously computed the result for these inputs. If it has, it returns the cached result instead of recalculating it.
Remember, you wrote selectCurrentUsersProfile
like this:
const selectCurrentUsersProfile = (state: RootState) =>
userProfileSelectors.selectById(state, selectCurrentUsersId(state));
You don't need to use createSelector
here because every selector from the .getSelectors
method already memoizes the result. In other words, the version using createSelector
and the version using selectById
are equivalent.
Next, you can use function composition together with createSelector
to create a memoized selector that returns true
if the user is authenticated.
export const selectIsAuthenticated = createSelector(
selectCurrentUsersProfile,
complement(isNil),
);
This value is derived based on whether there is a current user profile in the state.
Actually, even for this selector, the use of createSelector
in the app of this tutorial is contrived and unnecessary. All these selectors change infrequently and therefore are no performance bottlenecks. But I still wanted to teach you this important API. You should only use createSelector
in your app in the future when you're experiencing performance problems due to too many re-renders, or if the derivation of the value in your selector is computationally expensive.
You might want to watch "What Is Memoization (In JavaScript & TypeScript)" to get a better understanding when its worth to use memoization. The link is in the description.
Lastly, you create selectUsersList
, which is a selector that returns an array of all users.
Add the user profile slice to your root reducer.
import { combineReducers } from '@reduxjs/toolkit';
import {
name as appLoadingSliceName,
reducer as appLoadingReducer,
} from '@/features/app-loading/app-loading-reducer';
import {
name as userAuthenticationSliceName,
reducer as userAuthenticationReducer,
} from '@/features/user-authentication/user-authentication-reducer';
import {
name as userProfileSliceName,
reducer as userProfileReducer,
} from '@/features/user-profiles/user-profiles-reducer';
export const rootReducer = combineReducers({
[appLoadingSliceName]: appLoadingReducer,
[userAuthenticationSliceName]: userAuthenticationReducer,
[userProfileSliceName]: userProfileReducer,
});
Sagas In TypeScript
It's time to handle your asynchronous logic. You're going to use sagas for that. If you're unfamiliar with sagas, watch the second video in this series. The link is in the description.
Before you can write your first saga, you need some asynchronous functions to interact with an API. It's good practice to abstract away API calls using facades. So, create two functions in src/features/user-profiles/user-profiles-api.ts
.
import axios from 'axios';
import { UserProfile } from './user-profiles-types';
export const getCurrentUserRequest = (
token: string,
): Promise<UserProfile | null> =>
token
? axios
.get<UserProfile>(`https://jsonplaceholder.typicode.com/users/1`)
.then(({ data }) => data)
: new Promise(resolve => {
setTimeout(() => {
resolve(null);
}, 1000);
});
export const getUsersRequest = (token: string) =>
axios
.get<UserProfile[]>('https://jsonplaceholder.typicode.com/users')
.then(({ data }) => data);
These functions talk to a placeholder API to fetch some user data. You're using this placeholder API because creating a real API from scratch would be beyond the scope of this tutorial.
Additionally, getCurrentUserRequest
simulates the absence of a user based on whether there's a token or not. In a real-world app, your API would respond differently depending on the user's authentication status, but since you're working with a placeholder API here, you need to fake that behavior.
With your facades ready, you can write your sagas.
import { createAction } from '@reduxjs/toolkit';
import { call, put, select, takeLeading } from 'redux-saga/effects';
import { selectAuthenticationToken } from '../user-authentication/user-authentication-reducer';
import {
currentUserProfileFetched,
name,
usersListFetched,
} from '../user-profiles/user-profiles-reducer';
import { getCurrentUserRequest, getUsersRequest } from './user-profiles-api';
export function* handleFetchCurrentUsersProfile() {
const token: ReturnType<typeof selectAuthenticationToken> = yield select(
selectAuthenticationToken,
);
const user: Awaited<ReturnType<typeof getCurrentUserRequest>> = yield call(
getCurrentUserRequest,
token,
);
if (user) {
yield put(currentUserProfileFetched(user));
}
}
function* handleFetchUserProfiles() {
const token: ReturnType<typeof selectAuthenticationToken> = yield select(
selectAuthenticationToken,
);
const users: Awaited<ReturnType<typeof getUsersRequest>> = yield call(
getUsersRequest,
token,
);
yield put(usersListFetched(users));
}
export const fetchUserProfiles = createAction(`${name}/fetchUserProfiles`);
export function* watchFetchUserProfiles() {
yield takeLeading(fetchUserProfiles.type, handleFetchUserProfiles);
}
Import the facades and various effects from Redux Saga.
The first saga you're creating handles fetching the current user's profile.
It first gets the authentication token from the store, then fetches the user. If the fetch is successful (meaning the user is authenticated), it dispatches the user to the store.
If you've watched the second video of this series, you should be familiar with select
, call
and put
. select
returns values from the store, call
invokes a function or another saga, and put
dispatches an action to the store.
What's new here is using ReturnType
and Awaited
to infer the types of token
and user
. TypeScript has a hard time inferring types automatically in generators, which is why you need to explicitly type your variables like this.
If you've never seen ReturnType
, here is how it works under the hood:
// typeof selectAuthenticationToken
type SelectAuthenticationToken = (state: RootState) => string;
// const selectAuthenticationToken = (state: RootState): string => '';
type Token: ReturnType<SelectAuthenticationToken>; // string
const token: string = '...'; // token: Token
It's a generic type that takes a function and returns the type of the value that function returns.
The next saga handles the fetching of all user profiles. It also selects the token from the store, fetches a list of users, and finally dispatches that list.
You'll use handleFetchCurrentUserProfile
in other sagas. To trigger handleFetchUserProfiles
, you need to create an action using createAction
.
Then you can create a watcher saga for it. This "watcher-handler pattern" is common in Redux Saga. The watcher saga usually uses takeEvery
, takeLeading
, or takeLatest
, which are effects you're seeing for the first time in this series. Every one of these effects takes in an action and then a saga.
takeEvery
listens for every dispatched action of a specific type and runs a saga each time. It handles concurrent tasks.takeLeading
listens for actions of a specific type and runs a saga for the first action. It ignores any further actions until the triggered saga completes.takeLatest
also listens for actions of a specific type but cancels any previously running saga and starts a new one with the latest action.
Using takeLeading
, you can create a saga that fetches the user profiles every time fetchUserProfiles
is dispatched, blocking additional actions from triggering that fetch again. You want to block the saga here in case the action gets dispatched multiple times in rapid succession, so you only fetch once.
Now it's time to handle the authentication sagas. Again, you first need to set up requests to log the user in and out.
export const loginRequest = (email: string, password: string) => {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
resolve('example-dummy-token');
}, 2000);
});
};
export const logoutRequest = () => {
return new Promise<void>((resolve, reject) => {
setTimeout(() => {
resolve();
}, 2000);
});
};
For the same reasons as above - because writing a complete server with auth logic is beyond the scope of this video - you're going to "fake" both of these requests. In a real-world app, your server would generate the JWT token when the user logs in, and destroy it when the user logs out.
loginRequest
simulates an API request that returns a JWT token. And logoutRequest
simulates clearing out the login state on the server, like invalidate a session. So logoutRequest
is literally just a placeholder.
Now, implement the sagas to handle these requests.
import { createAction } from '@reduxjs/toolkit';
import { call, put, takeLeading } from 'redux-saga/effects';
import { clear } from '@/redux/clear';
import { handleFetchCurrentUsersProfile } from '../user-profiles/user-profiles-saga';
import { loginRequest, logoutRequest } from './user-authentication-api';
import {
login,
loginSucceeded,
name,
stopAuthenticating,
} from './user-authentication-reducer';
function* handleLogin({
payload: { email, password },
}: ReturnType<typeof login>) {
try {
const token: Awaited<ReturnType<typeof loginRequest>> = yield call(
loginRequest,
email,
password,
);
yield put(loginSucceeded({ token }));
yield call(handleFetchCurrentUsersProfile);
} finally {
yield put(stopAuthenticating());
}
}
export function* watchLogin() {
yield takeLeading(login.type, handleLogin);
}
export const logout = createAction(`${name}/logout`);
export function* handleLogout() {
yield put(clear());
yield call(logoutRequest);
}
export function* watchLogout() {
yield takeLeading(logout.type, handleLogout);
}
For both login and logout, you're going to use the watcher-handler pattern. Watchers are like conductors in an orchestra and the handlers are the musicians. The watcher listens for actions and triggers the handler, which handles the logic that should run when the action is dispatched.
Create a saga that handles the login logic. The saga receives the login()
action with its payload, so you can destructure the user's email and password from it. It gets a token by executing the login request. Then it dispatches the token to the store, and finally it fetches the current user's profile. Here, you pass the handleFetchCurrentUsersProfile
saga as an argument to call
, which works because call
allows you to invoke a function or another saga and wait for its result. This is useful for managing sequential asynchronous operations within sagas. Finally, it stops authenticating by setting isAuthenticating
to false
in the user authentication slice.
Next, set up the watcher for the login action. Remember that any take effect passes the action that triggered it to the saga in its second argument, which is how handleLogin
gets access to the action in its parameters.
Create a logout action and a saga that handles the logout. It dispatches the clear
action, resetting the user authentication and user profile reducers to their initial state, and then calls the logout request.
Finally, create a watcher that connects the logout action to the handleLogout
saga. Remember, the job of any watcher saga is to trigger the handler saga and block any other action from triggering it again while it runs.
all
Effect
You need one more watcher-handler saga pair to handle the app's loading process.
import { createAction } from '@reduxjs/toolkit';
import { call, put, takeLeading } from 'redux-saga/effects';
import { handleFetchCurrentUsersProfile } from '@/features/user-profiles/user-profiles-saga';
// Since we only import from one reducer, there is no need to use `as` to
// avoid naming conflicts.
import { finishedAppLoading, name } from './app-loading-reducer';
export const loadApp = createAction(`${name}/loadApp`);
function* handleLoadApp() {
yield call(handleFetchCurrentUsersProfile);
yield put(finishedAppLoading());
}
export function* watchLoadApp() {
yield takeLeading(loadApp.type, handleLoadApp);
}
There is nothing new here. You define a loadApp
action and a saga that fetches the current user's profile, then sets the appIsLoading
boolean to false
using the finishedAppLoading
action.
If your app needs to load more than just one resource, you can use the all
effect in this saga to fetch multiple resources in parallel.
function* handleLoadApp() {
yield all([
call(handleFetchCurrentUsersProfile),
call(handleFetchAccountBalance), // imaginary example
call(handleFetchUsageLimits), // imaginary example
]);
yield put(finishedAppLoading());
}
This allows you to perform all the necessary API requests to load the app simultaneously.
Lastly, you set up the watcher saga for the loadApp
action.
Root Saga
Just like you combine all your reducers into a root reducer, you also combine all your sagas into a root saga using the all
effect.
import { all } from 'redux-saga/effects';
import { watchLoadApp } from '@/features/app-loading/app-loading-saga';
import {
watchLogin,
watchLogout,
} from '@/features/user-authentication/user-authentication-saga';
import { watchFetchUserProfiles } from '@/features/user-profiles/user-profiles-saga';
export function* rootSaga() {
yield all([
watchFetchUserProfiles(),
watchLoadApp(),
watchLogin(),
watchLogout(),
]);
}
The root saga uses the all
effect to run watchFetchUserProfiles
, watchLoadApp
, watchLogin
, and watchLogout
concurrently, allowing your application to handle multiple types of actions at the same time.
Hook up the root saga in your makeStore
function.
import { configureStore } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import { rootReducer } from './root-reducer';
import { rootSaga } from './root-saga';
export const makeStore = () => {
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({ thunk: false }).concat(sagaMiddleware),
});
sagaMiddleware.run(rootSaga);
return store;
};
// Infer the type of makeStore's store.
export type AppStore = ReturnType<typeof makeStore>;
// Infer the `RootState` and `AppDispatch` types from the store itself.
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
Import the createSagaMiddleware
function and the root saga.
Inside the makeStore
function, create the saga middleware and add it to your middleware array using concat
.
Finally, run your root saga with sagaMiddleware.run(rootSaga);
.
connect
HOC In TypeScript
It's time to create the UI for the app loading logic. You're going to use the connect
higher-order component (HOC) with the display- / container component pattern.
Create a simple display component that shows the loading state.
import { Spinner } from '@/components/spinner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function AppLoadingComponent() {
return (
<div className="w-full p-4">
<Card className="mx-auto w-[350px]">
<CardHeader>
<CardTitle className="mx-auto">Loading ...</CardTitle>
</CardHeader>
<CardContent>
<Spinner className="mx-auto animate-spin" />
</CardContent>
</Card>
</div>
);
}
Use the connect
HOC to create the container component.
'use client';
import { useEffect } from 'react';
import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
import { AppLoadingComponent } from './app-loading-component';
import { loadApp } from './app-loading-saga';
const mapDispatchToProps = { loadApp };
const connector = connect(undefined, mapDispatchToProps);
type AppLoadingPropsFromRedux = ConnectedProps<typeof connector>;
function AppLoadingContainer({ loadApp }: AppLoadingPropsFromRedux) {
useEffect(() => {
loadApp(); // triggers the `handleLoadApp` saga via `takeLeading` watcher
}, [loadApp]);
return <AppLoadingComponent />;
}
export default connector(AppLoadingContainer);
The main difference when using connect
with TypeScript compared to JavaScript is that you use the ConnectedProps
helper to infer the types of the props that connect
injects. You do this by first calling connect
as a partial application without a component to create the connector
. Then you wrap your component with the connector
.
In this case, the container component dispatches the loadApp
action using useEffect
. However, fetching data inside useEffect
is considered an anti-pattern because:
useEffect
doesn't support server-side rendering, so data becomes available only after the page loads, causing delays.- It can create "network waterfalls," where nested components fetch data one after another, slowing down the load time.
- Without caching or data management, data isn't saved across component re-mounts, leading to repeated network requests and performance issues.
- Managing data fetching in
useEffect
adds code complexity and increases the chance of bugs through race conditions.
I'm still showing you this because many Redux apps fetch data like this. You're going to learn how to do this right in another video.
Next, create the display component for the login page by copying the code from the authentication-01
block from Shadcn to app/features/user-authentication/user-authentication-page-component.tsx
, and modifying it to handle props.
import { Spinner } from '@/components/spinner';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { UserAuthenticationPropsFromRedux } from './user-authentication-container';
export function UserAuthenticationComponent({
isLoading,
onLogin,
}: UserAuthenticationPropsFromRedux) {
return (
<main className="p-4">
<Card className="mx-auto w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your email below to log in to your account.
</CardDescription>
</CardHeader>
<form
onSubmit={event => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
onLogin({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
}}
>
<fieldset disabled={isLoading}>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required />
</div>
</CardContent>
<CardFooter>
<Button className="w-full" type="submit">
{isLoading ? (
<span className="flex items-center">
Authenticating ...
<Spinner className="ml-2 h-4 w-4 animate-spin" />
</span>
) : (
'Login'
)}
</Button>
</CardFooter>
</fieldset>
</form>
</Card>
</main>
);
}
There's nothing particularly special going on here.
Since this component will receive props from Redux, you import UserAuthenticationPropsFromRedux
from the container component, which we're about to create next. Other than that, it's just a simple card that provides a form for users to log in. This form is disabled and shows a spinner while the login request is in progress.
You can write the container component without any JSX because you're passing all the props to the display component and you're accessing none in the container component.
'use client';
import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
import { RootState } from '@/redux/store';
import { UserAuthenticationComponent } from './user-authentication-component';
import { login, selectIsAuthenticating } from './user-authentication-reducer';
const mapStateToProps = (state: RootState) => ({
isLoading: selectIsAuthenticating(state),
});
const mapDispatchToProps = { onLogin: login };
const connector = connect(mapStateToProps, mapDispatchToProps);
export type UserAuthenticationPropsFromRedux = ConnectedProps<typeof connector>;
export default connector(UserAuthenticationComponent);
Again, this is a classic container component in TypeScript. You connect the isAuthenticating
state using mapStateToProps
and inject the login
action via mapDispatchToProps
. Export the UserAuthenticationPropsFromRedux
type, which we referenced earlier in the display component. Finally, wrap your display component with the connector
.
There is one more page you need to build, which is the dashboard page. Start with the display component.
import { Spinner } from '@/components/spinner';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { DashboardPagePropsFromRedux } from './dashboard-page-container';
export function DashBoardPageComponent({
currentUsersName,
isLoading,
users,
onLogout,
}: Omit<DashboardPagePropsFromRedux, 'fetchUserProfiles'>) {
return (
<main className="p-4">
<Card className="mx-auto max-w-sm">
<CardHeader>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Welcome back, {currentUsersName}!</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Spinner className="mx-auto animate-spin" />
) : (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</CardContent>
<CardFooter>
<Button
className="ml-auto"
variant="destructive"
onClick={() => {
onLogout();
}}
>
Logout
</Button>
</CardFooter>
</Card>
</main>
);
}
The component takes in the DashboardPagePropsFromRedux
, which come from the connect
HOC again, but you omit fetchUserProfiles
, which will be wired up in mapDispatchToProps
but only used in the container component.
On this simple page, you display the current user's name. You also show a spinner while the users list is loading, and then render the users once they've loaded. Finally, the page has a button to log the user out.
The final container component wires up all the props for the display component.
'use client';
import { useEffect } from 'react';
import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
import { RootState } from '@/redux/store';
import { logout } from '../user-authentication/user-authentication-saga';
import {
selectCurrentUsersName,
selectUserProfilesAreLoading,
selectUsersList,
} from '../user-profiles/user-profiles-reducer';
import { fetchUserProfiles } from '../user-profiles/user-profiles-saga';
import { DashBoardPageComponent } from './dashboard-page-component';
const mapStateToProps = (state: RootState) => ({
currentUsersName: selectCurrentUsersName(state),
isLoading: selectUserProfilesAreLoading(state),
users: selectUsersList(state),
});
const mapDispatchToProps = {
fetchUserProfiles,
onLogout: logout,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type DashboardPagePropsFromRedux = ConnectedProps<typeof connector>;
function DashboardContainer({
fetchUserProfiles,
...props // contains the rest of the state and dispatch props
}: DashboardPagePropsFromRedux) {
useEffect(() => {
fetchUserProfiles();
}, [fetchUserProfiles]);
return <DashBoardPageComponent {...props} />;
}
export default connector(DashboardContainer);
You get the current user's name, whether the user profiles are loading, and the list of users with mapStateToProps
and your selectors.
Hook up the fetchUserProfiles
and logout
actions to dispatch via mapDispatchToProps
.
Export the DashboardPagePropsFromRedux
type, which you use to define the props of the display component.
Then, fetch the user profiles when the page loads using a useEffect
. Spread the remaining props into the display component.
Finally, export the connected container component.
HOCs For Authentication And Loading
You now have all the puzzles pieces in place to put them all together.
You're going to learn the old-school way of connecting everything using higher-order components. This is a very opinionated approach, so I want to highlight a comment I recently received on my "Unleash JavaScript's Potential With Functional Programming" video by the user @kairollmann
:
[...] Please everyone, be mindful when you're working in a team. It'll be rare that others in your team have studied and understood fp as much as you did. "Real" senior developers understand the value of easy-to-read, easy-to-maintain code. Junior devs create simple code for everyone on the team. Mid-level devs create opinionated, seemingly "so efficient", "so elegant" code. Senior devs create simple code again. [...]
Code readability and maintainability should always be top priorities. It's a balancing act between writing efficient code and ensuring that everyone on the team can understand and maintain it long-term.
- Sometimes, you want to use the best patterns and teach those who aren't familiar with them. (Especially in a mentorship culture.)
- In other cases, you want to reach for the best tool that everyone knows. ("When in Rome, do as the Romans do.")
My mentor taught me both.
I bring this up because while I believe the patterns you're about to learn are clean and good code, you need to be careful when applying them in teams. Always get team buy-in on the techniques you use and make sure everyone understands them.
Later in this video you'll learn how to use RTK Query, which is less controversial and more mainstream. But before that, you're going to see how you can wire up the HOCs and sagas. And I'll give you my reasons why I think the approach with HOCs and sagas is cleaner and scales better than using RTK Query.
With that disclaimer out of the way, you're going to create and compose half a dozen HOCs now. So, if you're unfamiliar with higher-order components, watch the video "Higher-Order Components Are Misunderstood In React" before resuming this video. The link is in the description.
Start by creating a RequiresPermission
component in src/hocs/requires-permission/requires-permission-component.tsx
.
interface Props<A, B> {
NotPermittedComponent: React.ComponentType<A>;
PermittedComponent: React.ComponentType<B>;
isPermitted: boolean;
}
export const RequiresPermissionComponent = <A, B>(props: Props<A, B> & A & B) => {
const { NotPermittedComponent, PermittedComponent, isPermitted } = props;
return isPermitted ? (
<PermittedComponent {...props} />
) : (
<NotPermittedComponent {...props} />
);
};
This simple component takes in an object with two React components and a boolean.
It then destructures all three and, based on whether isPermitted
is true
or false
, it either renders the PermittedComponent
or the NotPermittedComponent
. This RequiresPermissionComponent
is basically just a fancy if
statement disguised as a React component.
Using this display component, you can create the HOC.
'use client';
import { curry } from 'ramda';
import { connect } from 'react-redux';
import { RootState } from '@/redux/store';
import { RequiresPermissionComponent } from './requires-permission-component';
function requiresPermissionHoc(
NotPermittedComponent: React.ComponentType,
selector: (state: RootState) => boolean,
PermittedComponent: React.ComponentType,
) {
const mapStateToProps = (state: RootState) => ({
NotPermittedComponent,
isPermitted: selector(state),
PermittedComponent,
});
return connect(mapStateToProps)(RequiresPermissionComponent);
}
export default curry(requiresPersmissionHoc);
TODO: explain that connect
return a new component, not JSX.
This HOC is straightforward. It takes in a NotPermittedComponent
, a selector based on which the isPermitted
boolean is passed into the RequiresPermissionComponent
, and finally the PermittedComponent
. It then connects the RequiresPermissionComponent
to the Redux store. and you export it curried.
The order of arguments is important because after you curry them you follow the "data-last" principle, which makes the HOC useful. You always want supply the "not permitted" component first, then the condition of when to hide it and finally the component that should be hidden or shown based on the condition.
Therefore, you can use this HOC to render either one component or the other based on a selector value.
Now, you can create two HOCs with the requiresPermission
HOC.
'use client';
import UserAuthentication from '@/features/user-authentication/user-authentication-container';
import { selectIsAuthenticated } from '@/features/user-profiles/user-profiles-reducer';
import requiresPermission from './requires-permission';
export default requiresPermission(UserAuthentication, selectIsAuthenticated);
First, withAuth
, which partially applies the UserAuthentication
container component and the selectIsAuthenticated
selector to the requiresPermission
HOC to create a HOC that shows the user authentication interface if users are logged out.
'use client';
import AppLoading from '@/features/app-loading/app-loading-container';
import { selectAppFinishedLoading } from '@/features/app-loading/app-loading-reducer';
import requiresPermission from './requires-permission';
export default requiresPermission(AppLoading, selectAppFinishedLoading);
The second withLoading
HOC works similarly but it shows the AppLoading
container if the app has NOT finished loading yet.
You can easily imagine how you can further use requiresPermission
to create HOCs that guard certain pages to only be accessible by premium users or make certain settings pages only viewable by admins.
Now you can compose both of these HOCs together to create a HOC for authenticated pages.
import { compose } from '@reduxjs/toolkit';
import withAuth from './with-auth';
import withLoading from './with-loading';
export default compose(withLoading, withAuth);
You use the compose
from @reduxjs/toolkit
because it works better with React components, HOCs, and TypeScript than the compose
from Ramda.
Every authenticated page needs to load the basic app loading data, and users need to be authenticated.
You can create another HOC for public pages.
import { compose } from '@reduxjs/toolkit';
import withLoading from './with-loading';
export default compose(withLoading);
In this tutorial, it will only consist of one HOC to load the data of the app. But for a real-world app, both of these HOCs could contain more functionality that is shared between multiple pages, such as layouts and logging.
Another useful HOC is one that redirects based on permissions.
import { useRouter } from 'next/navigation';
import { curry } from 'ramda';
import { PropsWithChildren, useEffect } from 'react';
import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
import { RootState } from '@/redux/store';
function redirect(predicate: (state: RootState) => boolean, path: string) {
const isExternal = path.startsWith('http');
const mapStateToProps = (
state: RootState,
): { shouldRedirect: boolean } & Record<string, any> => ({
shouldRedirect: predicate(state),
});
const connector = connect(mapStateToProps);
return function <T>(
Component: React.ComponentType<Omit<T, 'shouldRedirect'>>,
) {
function Redirect({
shouldRedirect,
...props
}: PropsWithChildren<T & ConnectedProps<typeof connector>>): JSX.Element {
const router = useRouter();
useEffect(() => {
if (shouldRedirect) {
if (isExternal && window) {
window.location.assign(path);
} else {
router.push(path);
}
}
}, [shouldRedirect, router]);
return <Component {...props} />;
}
return connector(Redirect);
};
}
export default curry(redirect);
This HOC takes in a selector and a path. It then calculates whether the user should be redirected based on that selector.
If the user should be redirected, it either navigates the user to external links via window.location.assign
, or to internal destinations using Next.js's router.
You want to curry this HOC, so you can use it with partial applications.
Now, create a HOC in src/hocs/redirect-if-logged-in.ts
that redirects users if they're logged in by partially applying the selectIsAuthenticated
selector.
import { selectIsAuthenticated } from '@/features/user-profiles/user-profiles-reducer';
import redirect from './redirect';
export default redirect(selectIsAuthenticated);
Using the HOCs and your container components that you created earlier, it's now trivial to create the login and the dashboard page.
'use client';
import { compose } from 'redux';
import UserAuthentication from '@/features/user-authentication/user-authentication-container';
import withPublicPage from '@/hocs/public-page';
import redirectIfLoggedIn from '@/hocs/redirect-if-logged-in';
export default compose(
redirectIfLoggedIn('/dashboard'),
withPublicPage,
)(UserAuthentication);
You can use the redirectIfLoggedIn
HOC to make sure that users who visit the login page but are already authenticated get redirected to the dashboard.
And since the page should be accessible publicly by anonymous users, you also compose in the publicPage
HOC.
'use client';
import DashboardPage from '@/features/dashboard/dashboard-page-container';
import authenticatedPage from '@/hocs/authenticated-page';
export default authenticatedPage(DashboardPage);
For the dashboard, it's even easier because you only need to wrap the HOC for authenticated pages around the container component for the dashboard.
You can now run your app via npm run dev
and visit /login
.
When the page loads, you will see the app loading spinner.
Then you can enter your email and password to log in, which will redirect you to the dashboard.
The dashboard will fetch the users list and lets you log out again.
Breaking Down The Senior Dev Secrets
Maybe it was hard to follow what you actually built here, so let's dissect it together.
Remember, the authentication state of the user is determined by whether there is a token in the store or not. In real-world Redux apps, the login state is frequently persisted using the Redux Persist middleware, which saves the token in local storage.
Let's start with the flow for an anonymous user visiting the login page.
- An anonymous user visits
/login
. redirectIfLoggedIn('/dashboard')
checks if the user is logged in. Since they aren't, no redirect happens.withPublicPage
displays the "not permitted" component of thewithLoading
HOC, which is theAppLoading
container.AppLoading
dispatches theloadApp
action, triggering thehandleLoadApp
saga through the watcher.handleLoadApp
tries to fetch the user's profile viahandleFetchCurrentUsersProfile
.handleFetchCurrentUsersProfile
runs thegetCurrentUserRequest
. Because the user is anonymous and has no token, the facade returnsnull
, and nothing changes.- After
handleFetchCurrentUsersProfile
finishes,handleLoadApp
dispatches thefinishedAppLoading
action. finishedAppLoading
setsappIsLoading
tofalse
, prompting thewithLoading
HOC to show its permitted component, theUserAuthentication
container.- The anonymous user enters their credentials and submits, dispatching the
login
action, which triggers thehandleLogin
saga. handleLogin
executesloginRequest
, dispatchesloginSucceeded
to store the token, and then callshandleFetchCurrentUsersProfile
again.- This time,
handleFetchCurrentUsersProfile
retrieves a user via the token and dispatches the user into the store. - Since there's now a user in the store,
redirectIfLoggedIn('/dashboard')
redirects to the dashboard. withAuthenticatedPage
shows the permitted component, which is theDashboard
container component because the app is loaded and the user is authenticated.- The
Dashboard
container component dispatchesfetchUserProfiles
, which fetches and dispatchesusersListFetched
. This writes the users list to the store and sets theisLoading
boolean for the user profiles tofalse
. - The app is fully loaded. The user can now click "Log Out," which dispatches the
clear
action, resetting the state of all slices to the initial state and calling the log-out endpoint.
Let's also walk through the flow for an anonymous user visiting the dashboard page.
- An anonymous user visits
/dashboard
. withAuthenticatedPage
displays theAppLoading
component through thewithLoading
HOC, which dispatches theloadApp
action, triggering thehandleLoadApp
saga.handleLoadApp
tries to fetch the user's profile viahandleFetchCurrentUsersProfile
.handleFetchCurrentUsersProfile
executesgetCurrentUserRequest
. Since the user is anonymous and has no token, it returnsnull
, and nothing changes.- After
handleFetchCurrentUsersProfile
finishes,handleLoadApp
dispatches thefinishedAppLoading
action. - The
withAuthenticatedPage
HOC then shows theUserAuthentication
component, the "not permitted" component ofwithAuth
, because the user is anonymous. - The user enters their credentials and submits, triggering the
login
action and thehandleLogin
saga. handleLogin
executesloginRequest
, dispatchesloginSucceeded
to store the token, and callshandleFetchCurrentUsersProfile
.handleFetchCurrentUsersProfile
retrieves the user using the token and dispatches the user into the store.- The
withAuth
HOC now shows its permitted component, which is theDashboardContainer
. - The
Dashboard
container dispatchesfetchUserProfiles
, fetching the user profiles and dispatching them withusersListFetched
. - The app is now fully loaded. The user can click "Log Out," which dispatches the
clear
action, resetting the state and calling the log-out endpoint.
You can simulate the flows for a logged-in user by hardcoding a token in the store.
- An authenticated user visits
/login
. redirectIfLoggedIn('/dashboard')
checks if the user is logged in. It finds a token in the store.- The presence of the token triggers a redirect to
/dashboard
. withAuthenticatedPage
HOC starts and displays theAppLoading
component through thewithLoading
HOC, which dispatches theloadApp
action.- The
loadApp
action triggers thehandleLoadApp
saga. handleLoadApp
saga invokeshandleFetchCurrentUsersProfile
to fetch the user's profile.handleFetchCurrentUsersProfile
executes thegetCurrentUserRequest
using the stored token. It successfully retrieves the user data.- Once the user data is fetched,
handleLoadApp
dispatches thefinishedAppLoading
action. finishedAppLoading
changesappIsLoading
tofalse
. Now,withLoading
shows its permitted component:RequiresPermissionComponent
fromwithAuth
. This component then displays its permitted component, theDashboardPageContainer
, as the user is authenticated and the app has fully loaded.- The
Dashboard
container component might dispatch actions likefetchUserProfiles
to fetch additional data needed for the dashboard. - The user interacts with the dashboard. Clicking "Log Out" will dispatch the
clear
action, resetting all slices to their initial states and removing the token, effectively logging the user out.
The flow for an authenticated user visiting /dashboard
follows the same steps as the previous flow from point 4 onward.
You might be thinking: "My god, this is complicated! Isn't this completely over-engineered?"
Yes and no.
Yes, in the sense that older apps using Redux manually manage their state. This is one of the many drawbacks mentioned in the first video of this series. This flow is an example of such complex manual management of various states.
And even if you've been coding for many years and have maintained, or are still maintaining, apps with manual state management, you might be saying: "This flow is completely over-engineered."
This is where I would argue "No, it's not over-engineered, unless you need to solve this exact use case." What I mean by that is, if you only need to load the data we're loading in this tutorial and handle simple authentication, you can get rid of many of the techniques you learned here. But if you ever manage a more complex app, this architecture scales as smoothly as a Redux app with extensive boilerplate can scale. It's trivial to add more loading via the all
effect in the app loading saga, as mentioned earlier. Permission management is trivial via the HOCs too. Injecting more logic via HOCs is also simple. In the words of Eric Elliott:
"The primary benefit of HOCs is not what they enable (there are other ways to do it); it's how they compose together at the page root level." - Eric Elliott
If you were to expand this app built on this foundational architecture, every page could simply care about its own data. Everything shared is neatly abstracted away.
RTK Query
Earlier, you implemented data fetching and caching manually using Redux Sagas. While Sagas are flexible enough to handle even the most complex flows with side effects, due to their architecture their code always requires some minimum complexity, even for simple data-fetching tasks. And for most apps, the majority of side effects are simple CRUD tasks.
And these simple CRUD operations is where RTK Query shines.
RTK Query is a data fetching and caching tool built on top of Redux Toolkit. It addresses many of the issues with Redux mentioned at the end of the first video:
- Tracking loading state to display spinners in the UI.
- Preventing duplicate data requests.
- Using optimistic updates to make the UI feel faster.
- Managing cache lifetimes as the user interacts with the UI.
createApi
Since RTK Query is part of Redux Toolkit, you don't need to install anything extra.
You're going to add another feature to your app: fetching posts.
Create a file for the post data type at src/features/posts/posts-types.ts
.
export type Post = {
userId: number;
id: number;
title: string;
body: string;
};
Each post consists of a unique identifier, a reference to the ID of the user who created the post, as well as a title and a body.
Create a new file at src/features/posts/posts-api.ts
.
Note to Cheta: In the following example, destructure as always in the end. When you reveal the endpoints, reveal one at a time (first getPosts
then addPost
).
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { Post } from './posts-types';
export const {
middleware,
reducer,
reducerPath,
useGetPostsQuery, // created by addPost mutation
useAddPostMutation, // created by getPosts query
} = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com/',
// You can add more options here, like prepareHeaders, credentials, etc.
}),
tagTypes: ['Posts'],
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => 'posts',
providesTags: ['Posts'],
}),
addPost: builder.mutation<Post, Partial<Post>>({
query: body => ({
url: 'posts',
method: 'POST',
body,
}),
invalidatesTags: ['Posts'],
}),
}),
});
Import createApi
and fetchBaseQuery
, as well as the Post
type.
Then, use createApi
to define a reducer with state and some hooks to manage the data for a specific API. This bundle is commonly called an "API slice". The terminology can get a bit confusing here because the word "API" is used in so many different contexts referring to different things.
The reducerPath
is the name of the API slice created by createApi
in your Redux store.
baseQuery
expects a function that is used to talk to the API. To create the baseQuery
function, you can use the fetchBaseQuery
higher-order function, which sets the base URL for all endpoints of this API. In this case, you configure it to talk to the JSON placeholder API. fetchBaseQuery
can accept more configuration options to handle query strings, headers, and even supply a custom fetch function. You can look these up in the docs.
However, I wanted to show you how you could use the token from your store here to authenticate your requests. That is not needed here because the JSON placeholder API is public, but if you were talking to your own endpoint, you could do it like this.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type { RootState } from '@/redux/store';
import { selectAuthenticationToken } from '../user-authentication/user-authentication-reducer';
import type { Post } from './posts-types';
export const {
// ...
} = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com/',
prepareHeaders: (headers, { getState }) => {
const token = selectAuthenticationToken(getState() as RootState);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
}),
// ...
});
You can use the prepareHeaders
option, which has access to getState
in its second argument, to grab the token using your selectors and then configure the header with the token for all your API calls of this API slice.
Next, tagTypes
contains an array of tags that are used by RTK Query under the hood to manage and invalidate the cache.
Each API created by createAPI
can handle multiple endpoints. In this case, you're only going to talk to the posts
endpoint of the JSON placeholder API, but you could also configure more endpoints exposed by the API, such as users
, todos
, or albums
. In that case, you'd add more tags for the additional resources.
Define an endpoint to retrieve posts using builder.query
. The query
key is a function that tells you what endpoint to talk to. Since your function takes no arguments and simply returns 'posts'
, the API endpoint is going to be https://jsonplaceholder.typicode.com/posts
.
You can use the parameters of the query
function property to supply query parameters. For example, if you wanted to fetch a single post, it could look like this.
// ...
export const {
// ...
} = createApi({
// ...
endpoints: builder => ({
// ...
getPost: builder.query<Post, number>({
query: id => `posts/${id}`,
providesTags: ['Posts'],
}),
// ...
}),
});
The providesTags
property tells RTK Query that when data is fetched by this endpoint and is added to the cache, it's tagged with 'Posts'
. This helps RTK Query manage caching and invalidation properly.
Add another endpoint for creating posts using builder.mutation
. For this query
function, you also need to specify the body of the fetch request as well as the method. builder.mutation
handles all mutation HTTP methods (POST
, PUT
, DELETE
, etc.). The invalidatesTags
property specifies which tags to invalidate. Here, performing this mutation will invalidate the cache tagged as 'Posts'
, causing dependent queries to refetch if active.
The hooks you can destrucure get their names from these two endpoints. The getPosts
query endpoint will create the useGetPostsQuery
hook, and the addPost
mutation will create the useAddPostMutation
hook.
You can now destructure the middleware, the reducer and its path, as well as two hooks from this API slice.
If you're wondering how the state created by this reducer looks, I'll show it to you, but be warned - it is a bit complex. A populated state might look like this.
{
"queries": {
"getPosts(undefined)": {
"status": "fulfilled",
"data": [
{
"userId": 1,
"id": 1,
"title": "Example title",
"body": "Example body of the post"
},
{
"userId": 2,
"id": 2,
"title": "Another title",
"body": "Another example body of the post"
}
],
"error": null,
"isFetching": false,
"isInvalidated": false,
"fulfilledTimeStamp": 1633036800000
}
},
"mutations": {
"addPost": {
"1": {
"status": "fulfilled",
"data": {
"userId": 3,
"id": 101,
"title": "New post title",
"body": "Body of the new post"
},
"error": null
}
}
},
"provided": {
"Posts": [
{ "type": "Post", "id": 1 },
{ "type": "Post", "id": 2 },
{ "type": "Post", "id": 101 }
]
},
"subscriptions": {
"getPosts(undefined)": {
"1": {
"isInvalidated": false,
"isPaused": false
}
}
},
"config": {
"online": true,
"focused": true,
"middlewareRegistered": true,
"keepUnusedDataFor": 60
}
}
There are several keys on this object. Without diving too deep into how RTK Query works under the hood, here's a brief explanation of each:
- queries: This section contains the state of the
getPosts
query. It shows afulfilled
status indicating successful data retrieval. The data array contains example posts with their respective properties. - mutations: This section tracks the state of any mutations, like
addPost
. In this example, a new post has been successfully added (note the fictional ID "1" for tracking this particular mutation). - provided: Lists the tags provided by the endpoints, indicating which resources are currently available in the cache.
- subscriptions: Keeps track of which components or entities are currently subscribing to which queries or mutations. TODO: add a little info about RTK subscriptions.
- config: Contains global configuration states like network status and middleware registration status.
RTK Query uses this to manage your state and loading states for you. Just like it's more important to understand how the pedals and steering wheel work when driving a car, it's more important here that you understand the RTK Query API rather than how it works internally.
You might have noticed that we didn't create any selectors. The main reason why I'm showing you this is because if you ever want to create a selector that grabs state from the posts, you'd need to consider this complex state shape.
Here is what a selector would look like to grab the normalized posts.
export const selectPostsNormalized = state =>
state.postsApi.queries['getPosts(undefined)']?.data;
But unless you're combining RTK Query with regular Redux code, you rarely need selectors for RTK Query. You will see the reason for that, soon.
Use the reducerPath
and the reducer
together to add the slice to your root reducer.
import { combineReducers } from '@reduxjs/toolkit';
import {
name as appLoadingSliceName,
reducer as appLoadingReducer,
} from '@/features/app-loading/app-loading-reducer';
import {
reducer as postsReducer,
reducerPath as postsSliceName,
} from '@/features/posts/posts-api';
import {
name as userAuthenticationSliceName,
reducer as userAuthenticationReducer,
} from '@/features/user-authentication/user-authentication-reducer';
import {
name as userProfileSliceName,
reducer as userProfileReducer,
} from '@/features/user-profiles/user-profiles-reducer';
export const rootReducer = combineReducers({
[appLoadingSliceName]: appLoadingReducer,
[postsSliceName]: postsReducer,
[userAuthenticationSliceName]: userAuthenticationReducer,
[userProfileSliceName]: userProfileReducer,
});
And then import and add the middleware to your store.
import { configureStore } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import { middleware as postsMiddleware } from '@/features/posts/posts-api';
import { rootReducer } from './root-reducer';
import { rootSaga } from './root-saga';
export const makeStore = () => {
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
// The middleware are independent of each other, so their order does not
// matter.
getDefaultMiddleware().concat(sagaMiddleware, postsMiddleware),
});
sagaMiddleware.run(rootSaga);
return store;
};
// Infer the type of makeStore's store.
export type AppStore = ReturnType<typeof makeStore>;
// Infer the `RootState` and `AppDispatch` types from the store itself.
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
Data Fetching With RTK Query
Now that you've set up the API slice and added the middleware, you're ready to use the hooks generated by RTK Query in your components.
You can create a React component that displays a list of posts at src/features/posts/posts-list-component.tsx
.
import { Spinner } from '@/components/spinner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useGetPostsQuery } from './posts-api';
export function PostsListComponent() {
const { data: posts, error, isLoading, refetch } = useGetPostsQuery();
if (isLoading) {
return <Spinner className="mx-auto animate-spin" />;
}
if (error) {
// Determine the error message based on the type of error
let errorMessage = 'An unknown error occurred';
if ('status' in error) {
errorMessage = `An error occurred: ${error.status} ${error.data ? JSON.stringify(error.data) : 'Unknown error'}`;
} else if ('message' in error) {
errorMessage = `An error occurred: ${error.message}`;
}
return (
<div className="text-red-500">
{errorMessage}
<Button onClick={refetch}>Retry</Button>
</div>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Latest Posts</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc pl-5">
{posts?.slice(0, 5).map(post => (
<li key={post.id}>
<strong>{post.title}</strong>
<p>{post.body}</p>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
Import some UI components and then your useGetPostsQuery
HOC.
As soon as this component renders, the query runs and fetches the posts, which are stored in posts
.
The isLoading
boolean is true
while the posts are being fetched, so you can use that to render a spinner.
If something goes wrong, the error
property will be set. An error usually looks like this.
{
"status": 404,
"error": "FetchBaseQueryError",
"data": {
"message": "Resource not found",
"documentation_url": "https://example.com/error-handling"
},
"originalStatus": 404
}
But sometimes these properties aren't set. To make TypeScript happy, you need to check which properties are present to craft and render your error message. You can also use the refetch
function returned by useGetPostsQuery
to refetch posts if something went wrong.
Finally, if you have posts, you can render a list of them by iterating over them using the .map
method.
Now you can also understand why RTK Query requires fewer selectors. You do NOT need selectors because the hooks already give you the relevant data and derived values.
Next, you can render your PostsListComponent
on a new page for the posts.
import { PostsListComponent } from './posts-list-component';
export function PostsPageComponent() {
return (
<main>
<div className="mx-auto max-w-xl">
<PostsListComponent />
</div>
</main>
);
}
To expose the page under /posts
, create a page file at src/app/posts/page.ts
.
'use client';
import { PostsPageComponent } from '@/features/posts/posts-page-component';
import authenticatedPage from '@/hocs/authenticated-page';
export default authenticatedPage(PostsPageComponent);
Notice how easy it is to add authentication and loading to a page using the authenticatedPage
HOC.
If you now run your app, visit /posts
, and either log in or hardcode a token in your state to simulate already being logged in, your app will automatically fetch and display the posts.
Mutating Data With RTK Query
Let's enhance the posts page further by adding a form that allows users to create new posts.
Create a new file at src/features/posts/add-post-component.tsx
.
import { Spinner } from '@/components/spinner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAddPostMutation } from './posts-api';
export function AddPostComponent() {
const [addPost, { isLoading }] = useAddPostMutation();
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const formData = new FormData(event.currentTarget);
const title = formData.get('post-title') as string;
const body = formData.get('post-body') as string;
await addPost({ title, body }).unwrap();
} catch (error) {
console.error('Failed to save the post:', error);
}
};
return (
<form onSubmit={onSubmit}>
<fieldset className="space-y-4" disabled={isLoading}>
<div>
<Label htmlFor="post-title">Title</Label>
<Input id="post-title" name="post-title" required />
</div>
<div>
<Label htmlFor="post-body">Body</Label>
<Input id="post-body" name="post-body" required />
</div>
<Button type="submit">
{isLoading ? (
<span className="flex items-center">
Saving...
<Spinner className="ml-2 h-4 w-4 animate-spin" />
</span>
) : (
'Add Post'
)}
</Button>
</fieldset>
</form>
);
}
Import some UI components and the useAddPostMutation
hook.
Destructure the addPost
function, which triggers the mutation, and the isLoading
state from the hook.
Define an onSubmit
function to handle the form's submission. Inside it, you grab the title and body from the form, then call addPost
with that data. Chaining .unwrap()
will throw an error if it occurs, so you can catch it. If you don't call .unwrap()
, you could also destructure the error
from the hook.
Then render the user interface for the form, disabling it while it's loading.
Import and render the AddPostComponent
in the PostsPageComponent
.
import { AddPostComponent } from './add-post-component';
import { PostsListComponent } from './posts-list-component';
export function PostsPageComponent() {
return (
<main>
<div className="mx-auto max-w-xl">
<AddPostComponent />
</div>
<div className="mx-auto mt-10 max-w-xl">
<PostsListComponent />
</div>
</main>
);
}
When you run your app and try to add a post, you'll find that it does not appear in your list of posts.
The reason for this is RTK Query's caching behavior. RTK Query automatically refetches the latest data from the server. But since you're using a dummy API in this tutorial, no new entities have been created, so the list never changes.
Understanding RTK Query's Cache Behavior
RTK Query handles caching and invalidation out of the box. Here's how it works in your setup:
providesTags
: In thegetPosts
query,providesTags: ['Posts']
tells RTK Query that this query provides data for the'Posts'
tag.invalidatesTags
: In theaddPost
mutation,invalidatesTags: ['Posts']
tells RTK Query that this mutation invalidates any queries associated with the'Posts'
tag.
When you add a new post using the addPost
mutation, RTK Query automatically re-fetches any queries that provide the 'Posts'
tag, ensuring your UI stays in sync.
But since the posts are saved in a dummy API, the POST request does nothing. No new post is created, so the list never changes. In your own app, you would create the resource and add it to the list of posts, so your list would change.
However, we can at least temporarily show something because there is one more important feature of RTK Query that you want to learn.
Optimistic Updates With RTK Query
RTK Query supports optimistic updates, which can make your UI feel more responsive by updating the UI before the server confirms the change.
Currently, when you add a post, you have to wait until the GET request resolves before you see the new post in the list. But with optimistic updates, you can temporarily show a temporary "fake" post with the data that you entered in the list while the GET request is still pending. And when the GET request resolves, the fake post is replaced by the real post. Here the difference between the fake post and the real post is only the id
property, which is automatically generated by the server.
Modify the addPost
mutation to support optimistic updates.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { Post } from './posts-types';
export const {
middleware,
reducer,
reducerPath,
useAddPostMutation,
useGetPostsQuery,
util,
} = createApi({
// ...
endpoints: builder => ({
// ...
addPost: builder.mutation<Post, Partial<Post>>({
// ...
invalidatesTags: ['Posts'],
async onQueryStarted(argument, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
util.updateQueryData('getPosts', undefined, draft => {
draft.push({ id: Date.now(), ...argument } as Post);
}),
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
}),
});
Add the onQueryStarted
function, which allows you to perform actions when the mutation starts. It receives two parameters. The first is the mutation data, here called argument
. The second is an object with a dispatch
property, which you can use to dispatch actions. This dispatch
provided by onQueryStarted
is special because it returns a patchResult
object, which you can use to undo the state change caused by the dispatch.
The primary action within onQueryStarted
is the optimistic update of the query data for retrieving posts (getPosts
). This is accomplished using util.updateQueryData
, a utility method provided by RTK Query for safely updating or modifying the cache of a given query. Basically, you put a fake post into your state that gets removed as soon as the real post result from the API query comes in.
You wrap the fulfillment of the query in a try...catch
block. If the API call for the mutation resolves successfully (i.e., the promise queryFulfilled
is resolved), the optimistic update remains in place. This means the temporary post added to the cache is replaced by the real value coming from the API query. If the mutation fails (i.e., the promise is rejected), the catch
block is executed. Inside this block, the patchResult.undo()
function is called to revert the optimistic update. This removes the temporary post from the cache, thus preventing any discrepancies between the client and server states.
When you run your app again and add a post, you'll see it popping up in the list of posts briefly, until the GET request resolves, which removes the optimistic update again.
Comparing RTK Query To Sagas
Now that you've seen how to use RTK Query to fetch data, let's compare it to the approach using Redux Sagas.
RTK Query Advantages
- Simplified Data Fetching: RTK Query abstracts away the complexities of data fetching, caching, and state management related to that.
- Auto-Generated Hooks: It generates hooks that you can directly use in your components.
- Built-in Caching and Refetching: RTK Query handles caching, deduplication of requests, and re-fetching on focus or network reconnection.
- Less Boilerplate: You write less code compared to setting up sagas and reducers for each data-fetching operation.
When To Use Sagas Over RTK Query?
- Complex Flows and Side Effects: Sagas allow you to handle complex synchronous and asynchronous flows that need to be triggered, stopped, and continued based on various conditions. You can easily orchestrate multiple actions, conditional logic, or perform actions based on other actions.
- Non-Data-Fetching Side Effects: Sagas are not limited to data fetching and can handle any side effects. For example, real-time data through web sockets or server-sent events.
- You Use TDD And Want To Unit Test Your Side Effects: Because sagas isolate side effects, they're easily TDD-able with unit tests, which RTK Query is NOT. You can cover RTK Query's logic only through functional or E2E tests.
- Learn Evergreen Software Principles: Sagas show you how to reliably isolate side effects and solve any task. You will be able to transfer similar patterns to different domains and tech stacks.
Pick the right tool for the job. To do that, you want to answer the following questions:
- What are you and your team most comfortable with?
- What is the project already using?
- How complex will the data flow be?
And remember, sagas and RTK Query can be used at the same time. In some apps, you might use both - RTK Query for simple data fetching and sagas for more complex side effects.
I love you very much. Thank you so much for watching. If you liked this video give it a thumbs up and subscribe to the channel.
And now go watch the fourth video of this series to learn how to use test-driven development to create a production-ready Redux app with React Native.