I’ve covered Redux on this blog in the past, but the “modern” approach is to use the Redux Toolkit which cleans up and simplifies some of the code. In some cases I found it more complicated, because it obviously does a lot and abstracts a lot.
Going over the basics
Let’s go over some of the basics of Redux first…
Redux is a global state management system. It’s made up of three key parts, the store which as the name suggests, is where your state is stored. The shape of the data within the store is defined by your needs (i.e. no prerequisites). The store is used for global state, so to partition the store for ease of working with your data, we create slices. We’ll generally have slices based upon the domain, so maybe a slice for feature flags, another for component state, another for an API and so on.
Next we have actions, these are (as the name suggests) used to perform actions against our data. In Redux we have an object with a type and some (optional) payload. The type is the name of the action,
{type: "INCREMENT", payload: 3 }
Finally we have reducers which we use, based upon the type of actions, to carry out updates of state. It’s important to remember that Redux data should be immutable, hence the reducer doesn’t change the state itself, but instead copies the state updating the new version of the state before passing this new copy back
RTK
Let’s now create a bare minimum Redux toolkit store
Note: I’m using TypeScript in my examples
import { configureStore } from "@reduxjs/toolkit" export const store = configureStore({ reducer: {}. }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;
The code above just creates a Redux store, there’s no reducers. This store is not too useful at this point, but bare with me, let’s now quickly pivot to look at how we might use our store in a React app
import { Provider } from "react-redux" import { store } from "./state/store.ts" <Provider store={store}> <App /> </Provider>
By using the Provider at the App level our store is now available to our entire app.
As mentioned, the store above is pretty useless at the moment, let’s use the example from Redux/RTK to add a simple counter to the store. This represents one slice of the store hence, we’ll name the file counter.slice.ts (although RTK tends to go with counterSlice.ts style names).
First create a folder for your slices within the store folder, i.e. store/slices. Ofcourse you can place these where best suit, but this is the folder structure I’m using here.
Create the file counter.slice.ts inside store/slices. This, as you probably realised, will contain the counter slice within the state manager, i.e. the data specific to the counter
import { createSlice } from "@reduxjs/toolkit"; interface CounterState { value: number; } const intialState: CounterState = { value: 0; }; const counterSlice = createSlice({ name: "counter", initialState, reducers: { increment: (state) => { state.value += 1; }, decrement: (state) => { state.value -= 1; } }, }); export const { increment, decrement } = counterSlice.actions; export default counterSlice.reducer;
In this example we appear to be mutating state, but behind the scenes createSlice handles copying our state (RTK uses Immer to handle such immutability), if you prefer you can use the more obvious state.value + 1.
This file includes the CounterState interface (obviously not required for pure JavaScript) which is a representation of what our counter state will look like, in this case we just store a number, the current counter value. We also have these actions within the reducer, increment and decrement.
We create an initialState so our store has any defaults or starting points, for example in this case we set count initially to zero.
Next, we need to export our actions using export const { increment, decrement } = counterSlice.actions and finally for this section, we now need to register the slice with the store, this is accomplished by adding it to the store reducer, like this
import counterReducer from "./counter/counterSlice"; export const store = configureStore({ reducer: { counter: counterReducer, } });
At this point, everything is hooked up in the store, so let’s use our increment/decrement actions. We use the useSelector to get our current state and useDispatch to dispatch the actions to the the store
import { useSelector } from "react-redux"; import { RootState } from "../state/store"; import { decrement, increment } from "../state/counter/counterSlice"; export const Counter = () => { const count = useSelector((states: RootState) => state.counter.value); const dispatch = useDispatch(); // dispatch actions return ( <div> <div>{count}</div> <button onClick={() => dispatch(increment())}>Increment</button> <button onClick={() => dispatch(decrement())}>Decrement</button> </div> }
Passing parameters
These examples are using simple actions. Let’s extend then by adding a incrementBy
Add a new action to counterSlice like this
incrementBy: (states, action: PayloadAction<number>) => { state.value += action.payload; } <em>Note: Whilst the += makes it look like we're mutating state, this is just a reminder, in this instance code within RTK is handling this as an immutable update.</em> The PayloadAction only handles a single parameter, hence if we want to handle multiple parameters, we need to create an interface (in TypeScript) to define them. Let's move from the counter example and assume our Router is accepting multiple query parameters which we want to store in our store. We'll use an example of the github URL where by you can access my blog post code via <a href="https://github.com/putridparrot/blog-projects" rel="noopener" target="_blank">https://github.com/putridparrot/blog-projects</a>, so the first parameter might be gitUser, in this case <em>putridparrot</em>, followed by the repo name - hence we'd create our queryParams.slice.ts as follows [code] import { PayloadAction, createSlice } from "@reduxjs/toolkit"; export interface IQueryParams { gitUser?: string; repoName?: string; } const initialState: IQueryParams = { gitUser: "", repoName: "", } const queryParamsSlice = createSlice({ name: "queryParams", initialState: initialState, reducers: { setQueryParams: (_, action: PayloadAction<IQueryParams>) => action.payload, }, }); export const { setQueryParams } = queryParamsSlice.actions; export default queryParamsSlice.reducer;
Everything’s pretty much as our counter example apart from the PayloadAction<IQueryParams>. In this example we’re actually just passing everything into the store so just returning the payload via setQueryParams.
Async actions
RTK supplies a way to incorporate async actions into the store. If we have something like a network call or another process we know may take some time, we’ll want to make it async. Just for a simple example, we’ll assume increment is now handled via either a network call or some longer running piece of code. We create an async thunk like this
export const incrementAsync = createAsyncThunk( "counter/incrementAsync", async (amount: number) => { // simulate a network call await new Promise((resolve) => setTimeout(resolve, 1000)); return amount; } );
We’re supplying the name of our function counter/incrementAsync here. We now need to add this function to our slice, so after the reducers we add the following
extraReducers: (builder) => { // we can accept state but for this example, we've no need builder.addCase(incrementAsync.pending, () => { console.log("Pending"); }).addCase(incrementAsync.fulfilled, (state, action: PayloadAction<number>) => { state.value += action.payload; }); }
Notice that each “case” is based upon some state which comes from createAsyncThunk. This is very cool as we now have the ability to update things based upon the current state of the async call, i.e. maybe we display a spinner for pending and remove when fulfilled or rejected occurs and in the case of rejected we now display an error toast message or the
Dispatch
To actually call into the store, i.e. call and action, we need to use code like the following
const dispatch = useDispatch<AppDispatch>();
Usually we’ll just wrap both the dispatch and selector into the following to make working with TypeScript simpler (just place this is some utility file such as hooks.ts or the classic helper.ts named file)
export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
So now we can get the current count and update like this
import { decrement, increment } from '../store/slices/counter.slice' import { useAppDispatch, useAppSelector } from '../store/hook' export function Counter() { const count = useAppSelector((state) => state.counter.value) const dispatch = useAppDispatch() return ( <div> <div> <button aria-label="Increment value" onClick={() => dispatch(increment())} > Increment </button> <span>{count}</span> <button aria-label="Decrement value" onClick={() => dispatch(decrement())} > Decrement </button> </div> </div> ) }
Calling services or API’s
The example within the RTK documentation is a call to the pokeapi, so let’s start with that. RTK documentation seems to suggest such API’s go into a folder named services, so we’ll stick with that although they are essentially just other slices of global state (at least in my view).
In our services folder add pokemin.ts and here’s the contents of the file
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' export const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), tagTypes: [], endpoints: (builder) => ({ getPokemonByName: builder.query({ query: (name: string) => `pokemon/${name}`, }), }), }) // Export hooks for usage in functional components export const { useGetPokemonByNameQuery } = pokemonApi
We used createAsyncThunk earlier and ofcourse we can easily implement services/network/API calls via the same mechanism, but the createApi method, which extends createAsyncThunk, generates React hooks for us. We also have options to handle caching and more from here.
In this example we use the fetchBaseQuery defining the baseUrl. This is a lightweight method wrapping the fetch method.
We define our endpoints, i.e. the API to our service and then at the end of the code we export the code generation hook, useGetPokemonByNameQuery.
To use this code within our store we need to go back to store.ts and add the following to the rootReducer
api: pokemonApi.reducer,
and to the middleware we need to concat the API like this
getDefaultMiddleware() .concat(pokemonApi.middleware),
Finally we’ll want to use this API, and we simply use the hook like this
export const Pokemon = ({ name, pollingInterval }: { name: string, pollingInterval: number }) => { const { data, error, isLoading, isFetching } = useGetPokemonByNameQuery( name, { pollingInterval, }, );
Notice that we now get those async thunk states, but named differently. So we have the data returned, a potential error. We also have isLoading and isFetching for handling progress info.
Multiple parameters and multiple API’s
As mentioned previously RTK accepts a single object as input and this is the same for createApi actions, so if you have a situation where you need to pass multiple parameters to your API, then you handle in the same way (although in this example I’m creating via an anonymous type), i.e.
getContract: builder.query<any, {contractId?: string, version?: string}>({ query: (arg) => { const { contractId, version } = arg; return { url: `someurl?contractId=${contractId}&version=${version}`, } },
With regards multiple API’s, let’s say we have a pokemon and contracts api (I admit an odd combination). Then to register them with the store, as seen with a single API we concat them with the middleware, but we don’t appear to concat each, instead we concat an array like this
getDefaultMiddleware() .concat([contractsApi.middleware, pokemonApi.middleware]),
Dynamic baseUrl and/or setting headers etc.
In the pokemon API example we had a fixed URL for the service, but when developing the front and back end we’ll often have dev and/or test and then production environments. In this case we’ll want to change the baseUrl based upon the environment. In such cases and also when we want to apply our own headers, for example let’s assume our API requires an SSO token, then we’ll want to include that in the call in some way or other.
Here’s our dynamicBaseQuery which we’re actually using the store to store something called enviroment which we’re assume contained data such as the the current environment URL along with the current SSO token for the user for the environment. In the second line we set the baseUrl and then it’s used in the fetchBaseQuery. We also use this to set up the headers.
const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (args, WebApi, extraOptions) => { const baseUrl = (WebApi.getState() as any).environment.appSettings.contractsApiUrl; const rawBaseQuery = fetchBaseQuery({ baseUrl, prepareHeaders: async (headers, { getState }) => { const token = (getState() as any).environment.token; if (headers) { headers.set("Accept", "application/json"); if(token) { headers.set("authorization", `Bearer ${token}`); } } return headers; }, }); return rawBaseQuery(args, WebApi, extraOptions); };
To use this we simple replace the baseQuery value with dynamicBaseQuery, like this snippet
export const contractsApi = createApi({ reducerPath: "contractsApi", baseQuery: dynamicBaseQuery, // rest of the code ommitted
Tooling
I highly recommend installing the Redux Dev Tools for your browser. These tools allow you to easily see the state of your Redux store (and as RTK is built on Redux we can see our RTK defined store) and best of all, RTK is automatically configured to work with the Redux Dev Tools, so now additional code is required.
Code
Code for this post is available in my blog-projects.
Note: Not all code is hooked up and being used, but hopefully there’s enough to show how things might look.