I spent some time experimenting with Redux and Typescript, and I finally got my head around it. I documented what I learned in this article, which will also serve as a tutorial on how to use Redux with Typescript, particularly in a React application.
While learning redux, I was trying to answer these questions:
- How can I fully benefit from Typescript's type system?
- How to properly inject dependencies into redux? (Hard dependencies are a code smell after all)
- How do I test all of this?
I answered all these questions throughout this article, enjoy!
NOTE: This is not a complete beginner tutorial, if you find it difficult to follow, I advise you to take a look at Typescript Quick Start tutorial from RTK.
What we are building
We are going to build a small React app that only has an authentication feature. Meaning that you can login, view the current user, and logout. This is enough to cover most of redux important concepts. There will be no backend, only mock data. If you want, you can later replace the mocks with a real API, and the Redux part will still work perfectly.
Here's a sneak peek of the final product.
I made a base project for your convenience, it has all the required packages, components, and services. So we can focus only on the Redux part. All you need to do is clone the github repo and install the packages:
Visit your localhost. You should see the login page.
The folder structure
/src/features/auth is everything we need for our authentication feature, the only thing missing is the redux logic.
data/subfolder contains the authentication repository and all its dependencies. It's there where we fake API calls and return mock data.
types/subfolder contains types used mainly by the data layer, but also used in other places throughout the app.
ui/subfolder contains React components.
Feel free to explore more on your own.
Let's start by adding a store to our app. We will be using Redux Toolkit package, it's the recommended way of using Redux. Create a new file
/src/app/ folder, and add the following code:
As you can see, we used
combineReducers to create a
rootReducer. And added the
createStore function, that returns a store configured with our
rootReducer is useless for now because it's "empty", we'll add the authentication reducer to it in a moment. But first, let's add some types that will help us a lot later.
First, we need the Store and the State types. Usually, these types keep changing a lot during development, since we constantly add new reducers and modify existing ones, so it's not a good idea to write them manually, and modify them every time we make a change. That's why we have to infer them, we'll use
ReturnType to do that, we give it a function type as a type parameter, and we get back that function's return type.
rootReducer is a function that returns a State, and
createStore is a function that returns a Store, we can infer our types the same way we did in the example above. Let's also get the type of the dispatch method. Add these lines to your
It's time to write some redux logic, but first, let's define what a "Slice" is. Quoting the docs:
You may be wondering, "what is a 'slice', anyway?". A normal Redux application has a JS object at the top of its state tree, and that object is the result of calling the Redux
combineReducersfunction to join multiple reducer functions into one larger "root reducer". We refer to one key/value section of that object as a "slice", and we use the term "slice reducer" to describe the reducer function responsible for updating that slice of the state.
Fair enough, let's create our authentication slice, add a file inside
/src/features/auth/ folder, and name it
auth-slice.ts. We need to define the type of the auth state, and while we're at it, let's also define the initial state, add this code to the file:
currentUser: is an object of type
Userif a user is logged in, otherwise it's
trueif the user is currently logging in, we'll use it to display some kind of spinner.
error: is the error that happened in the latest operation, or
nullif none happened.
Pretty simple, now let's create the actual slice:
We named it 'auth', we gave it the
initialState, and an empty
reducers will stay empty, because we're not interested in plain reducers, since they only change the state, and have no side effects. We can't put any data fetching logic inside a plain redux reducer. Instead, we need to use Middlewares.
The middleware we'll be using is redux-thunk, it lets you write plain functions that contain async code, and dispatch them to the store. Since we used RTK's
configureStore, the thunk middleware is automatically set up for us by default.
We'll make use of the handy
createAsyncThunk from RTK to create our first async thunk, which will be responsible for logging the user in. Add this code to the
As you can see,
createAsyncThunk expects 2 arguments:
- A name:
- A function: where we can put our async logic
This thunk does nothing for now, in order to make it useful, we need to know how we're going to use it, here's the scenario:
- The user enters his email/pass and clicks the login button
- we dispatch
signInWithEmailAndPasswordasync thunk, passing the email and pass as argument.
- The async function we passed to
createAsyncThunkgets the email/pass, and makes an API call to log the user in.
- If the login succeeds, the async function should return a
User. The thunk middleware will dispatch an action of type
loginWithEmailAndPass.fulfilled, with that user as a payload.
- If the login fails, the async function should return an
AuthErroras a rejected value. the thunk middleware will dispatch an action of type
loginWithEmailAndPass.rejected, with that error as a payload.
- If the login succeeds, the async function should return a
Since we want to make use of Typescript's type system, we need to add some type parameters.
createAsyncThunk accepts 3 type arguments, ordered as follows:
- The return type of the async function
- The type of the argument passed to the async function
- The thunk API type: it specifies the store's state type, its dispatch type, and the type of the reject value of the thunk being created (Along with other types) (This may be confusing at first but you'll understand it in a moment).
Let's start by specifying the type of our thunk API. We don't need to access the dispatch nor the state from our thunk, so we don't care about their types. We only need to specify the type of the rejected value, so our thunk API type will be like this:
Now let's add types to our
Now you'll notice a compile time error, since we must return
Promise<User | RejectValue<AuthError>>, our thunk is typed 😉.
Before we continue, there's something we have to take care of. We will be using
AuthRepository (located at
/src/features/auth/data/) to make API calls. We need to access it from our async thunk. We can do this in different ways: we can use a global variable (❌ not clean), we can pass it as an argument to our async thunk (❌ not that clean too), or we can inject it once into our thunk middleware when creating the store, and have access to it inside all our async thunks, which will also make testing cleaner (✅ clean). Let's do it.
First, let's instantiate an
AuthRepository. Usually, it's better to put all dependencies like this inside a single file, or use some kind of container to store them. Since we don't have that many dependencies, I'm not going to use a container.
/src/app/ folder, create a file
dependencies.ts, and copy the following code:
Now let's actually inject this into the store, go to
/src/app/store.ts, and modify your
extraArg is available in all our async thunks, we just need to do one last tweak. Remember our
ThunkApi type we wrote earlier, we'll add one more type to it, go back to
auth-slice.ts and add the
Let's also make our thunk's async function take the parameters we specified:
And now our async thunk is fully typed, if your IDE has autocompletion, you can see that
authRepo is there inside the
Last but not least, let's use
authRepo to sign the user in, here's the final version of
You may be confused about
isRight, but it's really simple. The
Promise<Either<AuthError, User>>. The
Either type can either be
Right. If it's
Left, we know that it's an
AuthError, else it's a
User. We're doing this because we want to catch all exceptions in the repository, and then return regular objects. It's better than writing
try...catch blocks everywhere. If you want to learn more about the Repository pattern, you can check my article here.
As you may recall from earlier, the thunk middleware will dispatch actions depending on the return value of the underlying async function. We didn't write any code that will handle these actions, let's do that now. Add the
extraReducers to the
authSlice as follows:
We just added extra reducers to handle actions coming from
loginWithEmailAndPass async thunk:
- The pending case: The API call is being made, we reset the previous
error, and set
- The fulfilled case: The API call was successful, and we got our user object. Save that user in the state and reset
loadingback to false.
- The rejected case: Some
errorhappened while making the API call, save that error in the state, and reset
loadingback to false.
We used the
builder syntax to make our reducers typed. If we used a simple object as the value for
action objects inside the reducer functions won't be typed.
Let's export the async thunk, as well as the main authentication reducer.
And finally, let's add the exported reducer to the store, go to
/src/app/store.ts, and add it:
Hooking redux to the components
We will use a provider on the top of the component tree to make the redux store accessible to all components. The components also need access to actions so they can dispatch them to the store, so we will provide them too using the Context API.
Custom redux hooks
react-redux library has some useful hooks to access the Redux API. Namely
useSelector. These hooks are not typed, we could import
AppDispatch and make them typed, but since we'll be doing it inside many components, it's better to create custom typed versions of these hooks, and use them instead. Create a file under
/src/app/ and call it
redux-hooks.ts, and add the following hooks to it:
Create a file under
auth-actions-context.tsx, and copy the following code:
We'll be using
useAuthActions hook instead of using
AuthActionsContext every time. The
AuthActionsProvider is there for the same purpose.
Instantiating the store
Let's instantiate a store, go to
/src/app/dependencies.ts and add the following code:
Providing the store
/src/index.tsx, and provide the store/actions:
Hooking the App component
/src/app/app.tsx, you'll notice we are using a
user variable which is always
null, and we use it to conditionally render
LoggedInPage. We need to use the store's state to decide which page to render.
We'll be using the
useAppSelector hook to access the state of the store we provided earlier, modify
app.tsx as follows:
To make sure everything works, run
npm start. You should still see the login page, because the user is initially
The Login page
/src/features/auth/ui/login-page.tsx, it has many lines of code as you can see, but we're only interested in the
loginClicked callback. It's fired when the user clicks the login button. For now, it only validates the email and password, then returns. Instead of just returning, let's actually log the user in.
First, let's grab the
dispatch method, and our
loginWithEmailAndPass action. Import
useAuthActions, then add the following lines to the top of the component:
Then, inside the
loginClicked function, dispatch
loginWithEmailAndPass action to the redux store:
Also, remove the hardcoded
error variables, and replace them with the ones existing in the auth state. Import
useAppSelector, and grab the state variables:
That's it, refresh the page, enter an email and a password, click login, and BOOM, they're incorrect 🤣. Use the following credentials to login:
Everything is working as expected, the progress indicator shows while the API is being called, an error snackbar appears if the login failed, and the
LoggedInPage is shown if the login succeeds.
The LoggedIn page
Go to the
First of all, You'll notice that we're using a hardcoded user, let's replace it with the user in the auth state. Import
shallowEqual, remove the hardcoded
user constant, and grab the actual user:
You will get a compile time error saying that
user can be null. This is normal since it's of type
User | null. But we are sure that
user is never
null if the
LoggedInPage is being displayed (Remember the conditional render in
app.tsx?). So it's safe to just throw an error if this ever happens:
Now login, and everything should work as expected.
Second of all, the logout button doesn't do anything. Let's change that.
In the same way I implemented
loginWithEmailAndPass async thunk, I'll also implement
logout. Check out the final
auth-slice.ts in this gist.
useAppDispatch, and dispatch the
logout action when the logout button is clicked:
Check out this gist for the final
Now login, click the logout button, and you should be logged out.
I promised that everything will be tested, but this article is already long enough. So, I'll leave testing to the next one, and will link it here once it's done.
It will be nice if the user can stay logged in after closing or refreshing the page, which is currently unsupported. Try to add this functionality to the app. You only have to add redux + component logic, the persisting is already done for you. You can just call
authRepo.getCurrentUser(), and it will return a
User | null depending on whether the user is logged in or not. Good luck!
This was a long tutorial, I hope it wasn't that confusing, and you actually learned something from it 😅. Let's recap all we've done so far:
- We created an authentication
Slicethat contains everything related to the authentication state of our app.
- We wrote
Async Thunksthat manipulate the state asynchronously.
- We injected dependencies to the
Store, so we can access them in all our
- We made sure to fully benefit from the type system.
- No hard dependencies, everything is injected/provided.
You can find the final code in this GitHub Repo.
I hope you had a good read, see you in the next 👋.