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:
1 //Installation2 yarn add redux react-redux @reduxjs/toolkit reselect redux-saga3 // OR4 npm i redux react-redux @reduxjs/toolkit reselect redux-saga5
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!
- web-core
- store
- .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.
1 import { Provider, useSelector, TypedUseSelectorHook } from "react-redux";2 import { configureStore } from "@reduxjs/toolkit";3 import { createWrapper } from 'next-redux-wrapper';45 const prod = Boolean(6 (process.env.REACT_APP_API || process.env.NODE_ENV) === "production"7 );89 /* We'll round back to these */10 // const sagaMiddleware = createSagaMiddleware();11 // import reducers from "@/web-core/reducers";12 // import sagas from "@/web-core/sagas";1314 export const store = configureStore({15 reducer: reducers,16 preloadedState: {},17 middleware: [sagaMiddleware],18 devTools: !prod,19 });2021 /* 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:
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.
12 import { createSlice, PayloadAction } from '@reduxjs/toolkit'34 type ModeProps = {5 mode: string | undefined;6 loading: boolean;7 error: string;8 }910 interface initialState: ModeProps {11 mode: undefined;12 loading: false;13 error: '';14 }151617 const modeSlice = createSlice(18 name: 'mode',19 initialState,20 reducers: {21 // Reducers go here22 }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:
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 });1819 const reducer = modeSlice.reducer;20 const actions = modeSlice.actions;2122 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 aGET
request,call
instructs our middleware to call a function with itsargs
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.
1 import { all, put, takeEvery } from "redux-saga/effects";2 import { actions } from "@/web-core/mode/slice";3 import { PayloadAction, createAction } from "@reduxjs/toolkit";45 const {6 setThemeMode,7 setFailure,8 } = actions;910 export const toggleThemeInit = createAction<string>("TOGGLE_THEME_INIT");1112 function* setThemeSaga(action: PayloadAction<string>) {13 console.log("PAYLOAD RECEIVED: ", action.payload);1415 let mode = action.payload;16 try {17 // checks local storage falls back to OS settings18 const isOsDark = window.matchMedia("(prefers-colors-scheme: dark)").matches;19 const osMode = isOsDark ? "dark" : "light";20 const curMode = mode || localStorage.getItem("theme") || osMode;2122 if (curMode === "light") {23 // swap root ids w/ preference24 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 mode34 yield put(setThemeMode(curMode));35 } catch (error) {36 yield put(setFailure(error));37 console.log("ERROR CHANGING THEMES: ", error);38 }39 }4041 export function* watchChangeMode() {42 yield takeEvery(toggleThemeInit.type, setThemeSaga);43 }4445 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.
1 const sagaMiddleware = createSagaMiddleware();2 import reducers from "@/web-core/reducers"; // alter path if not using Next3 import sagas from "@/web-core/sagas"; // alter path if not using Next4
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.
1 // reducers.tsx23 import { combineReducers } from "@reduxjs/toolkit";4 import { reducer as mode } from "./mode";567 export default combineReducers({8 mode,9 });1011 // sagas.tsx1213 import { sagas as modeSagas } from "./mode";1415 /* Reminder that the `yield all` in watchChangeMode was a demo. */16 const { watchChangeMode, toggleModeSaga } = modeSagas;1718 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!