Using Redux & Redux-sagas in 2023

Terms to Know

    • state -- The internal data a React component maintains. Within these components, state can be changed or mutated, and control how it behaves and renders.
    • props -- While state is mutable, or can be changed, props are the immutable, or unchangeable, parameters passed from parent to child components.

Ahh, Redux. It's a complicated, frustrating abstraction, but once it clicks, it morphs into a powerful, modular tool that becomes a part of your standard reset. Alternative solutions certainly exist, but Redux still sees the lion's share of usage with just under nine million weekly downloads.

Should you use Redux in 2023?

If you're building a simple, or mostly static, application with limited state changes, then probably not. React Context will serve you just fine for most personal projects.

Redux pulls its weight in more complex projects -- personal, work-related, or anything in between. As your application scales, the appeal of state management tools scales in turn.

It's worth noting that Redux is rarely used as a standalone solution. Myriad packages exist to help assuage the initial complexity, and for the most part, they work great. Unfortunately, the majority of guides and documentation are either outdated or too superficial to glean much from. Today we'll (hopefully) put some of that to rest... or add to the pile.

I know I know... more dependencies. I don't love it either, but if you've chosen Redux--or life chose it for you--it's still a skill worth learning, and many of its core concepts will carry over to newer, fresher solutions. When that time comes, you'll pick them up with ease.

So, if you're where I was, that hopelessly lost and confused, you've come to the right place. While this blog uses Next.js, the general structure doesn't change. Where it does, I'll be sure to make a note.

Now, you might be thinking something like, "What's your point?" Or maybe, "Get on with it."

To you, I say, "Yeah... okay."

The stack:

shell
1 //Installation
2 yarn add redux react-redux @reduxjs/toolkit reselect redux-saga
3 // OR
4 npm i redux react-redux @reduxjs/toolkit reselect redux-saga
5

What is Redux?

As JS apps ramp in complexity, our code manages and maintains far more state than ever. Redux's documentation puts this quite well:

"Managing this ever-changing state is hard. If a model can update another model, then a view can update a model, which updates another model, and this, in turn, might cause another view to update. At some point, you no longer understand what happens in your app as you have lost control over the when, why, and how of its state. When a system is opaque and non-deterministic, it's hard to reproduce bugs or add new features."

In short, Redux handles the entirety of your application's state within a single object tree, acting as your 'single source of truth' that behaves consistently across an entire application. It's also mostly framework agnostic, so once you have a feature up and running, it's easy to port from project to project.

To that end, we'll start with something I use in nearly all of my projects, including this blog: the vaunted dark mode.

No more history stalling, promise. Let's get after it.

How do I use it?

Here's how I generally structure my Redux folders. It may seem like a few too many index.ts files, but they do serve a purpose!

  • .gitignore
  • package.json
  • tsconfig.json

Redux works around the concept of a store. True to its name, this store is a central hub that stores the current state of your application. This becomes your 'single source of truth' as this global state is stored within a single object tree, ensuring data consistency across an application.

So your starting point will always be setting up your store. As mentioned, this is a Next.js blog, so there will be a tiny difference in implementation. We'll utilize the configureStore function via @reduxjs/toolkit and createWrapper via next-redux-wrapper.

tsx
1 import { Provider, useSelector, TypedUseSelectorHook } from "react-redux";
2 import { configureStore } from "@reduxjs/toolkit";
3 import { createWrapper } from 'next-redux-wrapper';
4
5 const prod = Boolean(
6 (process.env.REACT_APP_API || process.env.NODE_ENV) === "production"
7 );
8
9 /* We'll round back to these */
10 // const sagaMiddleware = createSagaMiddleware();
11 // import reducers from "@/web-core/reducers";
12 // import sagas from "@/web-core/sagas";
13
14 export const store = configureStore({
15 reducer: reducers,
16 preloadedState: {},
17 middleware: [sagaMiddleware],
18 devTools: !prod,
19 });
20
21 /* Next.js specific can be ignored for vanilla React */
22 const nextStore = () => store;
23 export const wrapper = createWrapper(nextStore, {debug: false});
24

With that, we have our store. The @reduxjs/toolkit dependency offers quite a few useful functions. Here we use configureStore, which automatically combines our reducers and middleware, and enables Redux DevTools in non-production environments. We also include an empty object as our initial store state.

The bottom portion is Next.js specific. We use createWrapper via next-redux-wrapper. This grants getInitialProps access to our store on the client side. Left unwrapped we'd need to create a separate store instance on the server, which sounds horrific.

Now that we have our store, what makes Redux tick?

Redux actions and reducers

We know that Redux stores the global state of our application within a single object tree and that this object tree is used throughout your application. To access this state, Redux utilizes two basic types: actions and reducers.

Actions are simple strings that bind a name to an action that will occur. Toggling dark mode would look something like this:

tsx
1 const toggleThemeInit = createAction<string>('TOGGLE_THEME_INIT');
2

Reducers, on the other hand, define what actually occurs when the action string is passed to Redux. Now, there are several ways to handle this step, but I always keep it in a slice.tsx or slice.js file. A reducer function can be as complex or as simple as you'd like. I prefer simple, as redux-saga handles the majority of my side effects.

tsx
1
2 import { createSlice, PayloadAction } from '@reduxjs/toolkit'
3
4 type ModeProps = {
5 mode: string | undefined;
6 loading: boolean;
7 error: string;
8 }
9
10 interface initialState: ModeProps {
11 mode: undefined;
12 loading: false;
13 error: '';
14 }
15
16
17 const modeSlice = createSlice(
18 name: 'mode',
19 initialState,
20 reducers: {
21 // Reducers go here
22 }
23 )
24

Ya gotta love Typescript's lovely boilerplate, but the gist is simple. Our ModeProps return three objects. Mode will always return either a string or undefined. More specifically: 'light', 'dark', or undefined.

We pass our ModeProps into our initialState interface via Typescript's key: Type pattern and set our initial values. We then initialize an array of objects using createSlice. To toggle our theme, we need to handle our theme switch, set the value in our store, and cover the errors that may arise. Each of these reducers can exist as a subset of a previous instruction or do its own thing entirely:

tsx
1 const modeSlice = createSlice({
2 name: 'mode',
3 initialState,
4 reducers: {
5 switchThemeMode: (state) => {
6 state.loading = true;
7 },
8 setThemeMode: (state, action: PayloadAction<string>) => {
9 state.loading = false;
10 state.mode = action.payload;
11 },
12 setFailure: (state, action: PayloadAction<any>) => {
13 state.loading = false;
14 state.error = action.payload;
15 },
16 },
17 });
18
19 const reducer = modeSlice.reducer;
20 const actions = modeSlice.actions;
21
22 export { reducer, actions }
23

switchThemeMode tells Redux that a new theme is loading. setThemeMode resets loading and sets our new theme, and setFailure handles any failures that might occur during the swap.

We export the created reducers and actions to use in our application and the last piece of our puzzle.

Enter Sagas

That final piece is redux-saga. It's certainly worth noting that this package is technically deprecated. Despite that, it sees around five million monthly downloads, so is it really dead, or just kind of "undead"?

Either way, I'm still using it, as I prefer to manage my effects, or how the state changes, in a single location that fires in unison. In other words, when a saga is called, all its instructions are run until we hit an error.

Sagas utilizes five main functions for handling side effects:

    • all -- Used to run any number of created sagas.
    • put -- PUT requests instructing the saga middleware to schedule an action dispatch to the store.
    • call -- Similar to a GET request, call instructs our middleware to call a function with its args as arguments.
    • takeEvery -- Spawns a saga on each action dispatched to our store that matches the provided pattern.
    • takeLatest -- Fetches only the latest saga call and cannot be run concurrently.

Your mileage may vary, but I use call, put, and takeEvery almost exclusively. But sagas offers one more function, and it's the most useful by a longshot. That function is yield.You can wrap any of the above in a yield to have it run concurrently, capturing each request in the order of arrival.

Now, let's see how it all looks.

tsx
1 import { all, put, takeEvery } from "redux-saga/effects";
2 import { actions } from "@/web-core/mode/slice";
3 import { PayloadAction, createAction } from "@reduxjs/toolkit";
4
5 const {
6 setThemeMode,
7 setFailure,
8 } = actions;
9
10 export const toggleThemeInit = createAction<string>("TOGGLE_THEME_INIT");
11
12 function* setThemeSaga(action: PayloadAction<string>) {
13 console.log("PAYLOAD RECEIVED: ", action.payload);
14
15 let mode = action.payload;
16 try {
17 // checks local storage falls back to OS settings
18 const isOsDark = window.matchMedia("(prefers-colors-scheme: dark)").matches;
19 const osMode = isOsDark ? "dark" : "light";
20 const curMode = mode || localStorage.getItem("theme") || osMode;
21
22 if (curMode === "light") {
23 // swap root ids w/ preference
24 document.documentElement.classList.remove("dark");
25 document.documentElement.classList.add("light");
26 localStorage.setItem("theme", "light");
27 }
28 if (curMode === "dark") {
29 document.documentElement.classList.remove("light");
30 document.documentElement.classList.add("dark");
31 localStorage.setItem("theme", "dark");
32 }
33 // Set preferred mode
34 yield put(setThemeMode(curMode));
35 } catch (error) {
36 yield put(setFailure(error));
37 console.log("ERROR CHANGING THEMES: ", error);
38 }
39 }
40
41 export function* watchChangeMode() {
42 yield takeEvery(toggleThemeInit.type, setThemeSaga);
43 }
44
45 export function* toggleModeSaga() {
46 yield all([watchChangeMode()]);
47 }
48

That was a lot to absorb, but I wanted to showcase as many features as possible. Sagas, and all concurrent functions, should be labeled with function*.

Note that yield all is unnecessary here and would only provide real value if multiple watchers existed in the scope. Otherwise, the flow is similar to what we've already seen. We create an action string representing the entire flow of instructions dictated within the saga.

We grab { mode } from our payload and check the current Redux mode state, followed by local storage, and finally, the user's browser settings.

If mode === "light" swap to an adaptive html light class, and vice versa for mode === "dark". We then yield our mode and establish a watcher to handle concurrent changes to our theme state.

Almost There

We can finally round back to store/store.ts. We're still missing our reducers.ts and sagas.ts. Fortunately, these solely bundle and export our code. Again, not necessary if the mode is the only Object in your store but incredibly powerful if you're managing several.

tsx
1 const sagaMiddleware = createSagaMiddleware();
2 import reducers from "@/web-core/reducers"; // alter path if not using Next
3 import sagas from "@/web-core/sagas"; // alter path if not using Next
4

We're still missing our reducers.ts and sagas.ts. Fortunately, these only bundle and export our code. Again, not necessary if mode is the only Object in your store but incredibly powerful if you're managing several.

tsx
1 // reducers.tsx
2
3 import { combineReducers } from "@reduxjs/toolkit";
4 import { reducer as mode } from "./mode";
5
6
7 export default combineReducers({
8 mode,
9 });
10
11 // sagas.tsx
12
13 import { sagas as modeSagas } from "./mode";
14
15 /* Reminder that the `yield all` in watchChangeMode was a demo. */
16 const { watchChangeMode, toggleModeSaga } = modeSagas;
17
18 export default [
19 toggleModeSaga,
20 ];
21

Wrapping up

And there you have it, a functional dark mode implementation using Redux and Redux Saga in 2023. This post was denser than expected, so we'll handle implementation in Part Two. We'll create a custom toggle switch and fully implement our Redux-centric dark mode!

Thanks for reading, skimming, or just dropping by!