Let’s go beyond the usual Redux tutorials. In this case study, we’ll learn the core concepts and APIs of Redux directly from its source code. I promise, this approach is both fun and much easier to understand!
Do we always need Redux? No.
Is Redux useful? Absolutely-yes, yes, yes!
Choosing Redux is a bit like choosing useMemo()
in React: it’s a powerful tool, but only when there’s a real need. There are alternative ways to solve problems that Redux addresses. For example, code splitting can help us avoid using useMemo()
and encourage us to think in a more "React way." So, should we avoid Redux? Or are there specific conditions when Redux is the right choice? To answer this, let’s not just follow the advice of experts like Dan Abramov. Instead, let’s dig deeper: what are the reasons behind the rules and pitfalls these experts suggest?
Learning by Building: One of the best ways to understand Redux is to build a simple version yourself. There are many tutorials online, like "Redux in 99 lines," but here, we’ll walk through the official Redux GitHub repo and create our own minimal Redux implementation. This hands-on approach helps build a strong, practical understanding.
Mental Models
Before jumping into code, let’s talk about mental models.
A mental model is simply a way to visualize and understand how something works in your mind.
If you know Josh Comeau, the teacher behind the "Joy of React" course, you’ll know he always starts with a mental model before diving into any topic. His explanations, especially about CSS layout algorithms, are inspiring and make complex topics much easier to grasp.
For example, Josh explains why an
tag with a border shows a weird bottom gap. This happens because the
tag is display: inline
by default. Inline elements are designed for text formatting, so the image is treated like a character in a paragraph, inheriting certain behaviors (like space for descenders). This is also why block-level elements like ,
,
, or
have default margins-they’re meant to structure documents.
Visual Example:
Add an image with a border, and you’ll see a gap at the bottom. This is because the image is inline, just like text, and it inherits the same layout rules.
Understanding this mental model makes it clear why some CSS behaviors seem strange at first. Similarly, having a mental model for Redux will help us understand how it works internally and when to use it.
What is Redux?
Redux is like useState()
—but for your entire application. It’s that simple.
Think of Redux as a global object with getter
, setter
, and pure functions that control how values change. Redux gives you a way to store, read, and update data globally in a predictable way.
In a more abstract form, Redux can be described as:
- An object (the state),
- A setter (
dispatch
), - A getter (
getState
), - And everything is pure (no side effects, predictable behavior).
Redux allows you to access and manipulate this central object from anywhere in your app.
Step 01: The Minimal Redux
let obj = { value: 1 };
function getter() { // Redux calls this getState
return obj;
}
function setter(d) { // Redux calls this dispatch
obj.value = d;
}
This is the most minimalistic version of Redux. From here, we’ll evolve it step-by-step into something closer to the actual implementation.
Before we continue, check out the official Redux documentation. Redux is built on three core principles:
- Single source of truth — The state of your whole application is stored in a single object tree.
- State is read-only — The only way to change the state is to emit an action.
- Changes are made with pure functions — Reducers are pure functions that take the previous state and an action, and return the next state.
Step 02: Aligning with Redux Concepts
Let’s now apply the three principles to our code, using variable names that align with Redux terminology:
let initialState = { value: 1, name: 'kuyili' };
let currentState = initialState;
function getState() {
return currentState;
}
function dispatch(d) {
currentState = { ...d, value: 2 };
}
We’ve renamed and restructured the variables, but dispatch
is still too simplistic. We need to improve it. Remember: we’re building a library, not just writing app logic.
When writing app code, you manipulate variables and pass values directly. But in a library, we should focus more on functions. Specifically, functions that:
- Take functions as arguments, and
- Return functions as results.
Let’s call this idea "Functionable."
I just explained what
combineReducers
is in Redux. After you finish this article and return to this point, you’ll understand why.
Step 03 – Let's Functionable
We’re now stepping into how Redux really works: by using functions to manage logic, not values. So let’s create a parent function that manages its child functions based on an action type—not by their names, but by using an object like { type: 'ACTION_NAME' }
.
// Step 03
function parentFunction(state, action) { // Redux calls her the "reducer"
switch (action.type) {
case 'ADD':
return (); // return updated state here
case 'SUB':
return (); // return updated state here
default:
return state;
}
}
function redux() { // Redux calls her the "createStore"
let initialState = { value: 1, name: 'kuyili' };
let currentState = initialState;
function getState() {
return currentState;
}
function dispatch(action) {
currentState = parentFunction(currentState, action);
// In Step 04, we’ll add logic here to notify subscribers
}
// ...
}
Looks nice, right?
But… we're not currently listening to changes in the state. Let's fix that using closures.
Step 04 – Closure Magic
This implementation is going to give you a clean understanding of subscribers as well as closures.
Closures are often confusing, right? Not really.
Closure is like a power booster, a magic spell, or Popeye’s spinach that gives your functions powers almost like a class component—but it still looks like a function.
Think of it like a function that remembers where it came from and everything around it, even when it's executed elsewhere.
Using closures, we’ll store subscribers (listener functions) in an array, and expose subscribe
and unsubscribe
features.
// Step 04
function reducer(state, action) {
switch (action.type) {
case 'ADD':
return { ...state, value: state.value + 1 };
case 'SUB':
return { ...state, value: state.value - 1 };
default:
return state;
}
}
function createStore(reducer) {
let state = { value: 1, name: 'kuyili' };
let listeners = [];
function getState() {
return state;
}
function dispatch(action) {
state = reducer(state, action);
listeners.forEach(listener => listener()); // MAGIC: Notify all subscribers
}
function subscribe(listener) {
listeners.push(listener);
return function unsubscribe() {
listeners = listeners.filter(l => l !== listener);
}
}
return {
getState,
dispatch,
subscribe
};
}
Looks great, right?
To create a subscriber, just call:
const unsubscribe = store.subscribe(() => {
console.log("State changed!", store.getState());
});
// To unsubscribe later:
unsubscribe();
This is the power of closure: when you call store.subscribe()
, it returns an unsubscribe()
function that still has access to the original listener
and listeners
array—even after subscribe()
is done executing.
If you look closely, you'll notice one thing was missing earlier: subscribers were not notified on dispatch.
We fixed it by simply adding:
listeners.forEach(listener => listener());
Every time dispatch()
is called (i.e. the state is updated), all subscribed listeners are notified.
🧠 Redux Concepts in a Table
API | Description |
---|---|
compose() |
Combines all middleware (right to left) |
combineReducers() |
Combines all reducers into one |
middleware |
You know what this does! |
📂 Stepping into the Redux Codebase
We’re now entering the actual Redux codebase to connect our simplified implementations to real-world source code.
🔖 Why Use a Tag Instead of a Branch?
Tags are stable versions. Branches can change, and files like
src/index.ts
might be moved or renamed.
At the time of writing, the latest tag isv5.0.1
.
Start here:
📁 index.ts
— The root that exports all Redux APIs.
You’ll notice we’ve already implemented:
📁 createStore.ts
— Our minimal version was a very close match!
🧩 combineReducers()
– Combining All Reducers
// simplified version
function combineReducers(reducers) {
return function combination(state = {}, action) {
const nextState = {};
let hasChanged = false;
for (const key in reducers) {
if (reducers.hasOwnProperty(key)) {
const previousStateForKey = state[key];
const nextStateForKey = reducers[key](previousStateForKey, action);
nextState[key] = nextStateForKey;
hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
}
}
return hasChanged ? nextState : state;
};
}
🔍 What's Going On?
-
reducers
is an object of reducer functions. - For each key:
- Get the previous state slice.
- Pass it with
action
to the corresponding reducer. - Save the result into
nextState
. - If any reducer returns a different state, set
hasChanged = true
.
- Finally, return the new
nextState
only if it changed, else return the originalstate
.
🔗 compose()
– Middleware Pipeline
// simplified version
function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg;
}
if (funcs.length === 1) {
return funcs[0];
}
return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
// example
function add5(x) {
return x + 5;
}
function multiplyBy2(x) {
return x * 2;
}
function subtract3(x) {
return x - 3;
}
const composedFunction = compose(subtract3, multiplyBy2, add5);
// Equivalent to: subtract3(multiplyBy2(add5(10)))
console.log(composedFunction(10)); // Output: 27
🔍 What's Going On?
- If no functions → return identity function (
x => x
) - If one function → return that directly
- Else → reduce from right to left using:
(...args) => a(b(...args))
So the result of b
becomes the input of a
, chaining them right-to-left.
API | Purpose |
---|---|
createStore() |
Core function that holds state and allows dispatching actions |
combineReducers() |
Combines multiple reducers into a single state management function |
compose() |
Combines middleware functions into a single pipeline (right to left) |
There are questions that might help you if you research them yourself, such as:
- How is Redux different from having your own class and using it, like exporting it?
- How is Redux different from an Angular service?
- How is Redux different from
localStorage
in a web browser? - Will this mental model and understanding help me understand the
useSelector()
implementation in thereact-redux
codebase? Here is the link. #### What's Next?
After playing with Redux and its codebase, the next step would be to explore React. Honestly, I couldn't understand the React source code very well. I spent more than two weeks trying to get a grasp of it, and I was only able to run the tests in thereact-scheduler
folder. Even then, I couldn't understand the code clearly.
Anyway, I moved on to the Preact source code, which has been really exciting. Why? Because if you go to their codebase, you'll see the implementation is really cool:
-
useState
is built on top ofuseReducer
under the hood. -
useRef
anduseCallback
are built on top ofuseMemo
under the hood.
Interesting, right? Simply examining their implementation gives us a deeper understanding of how to use these hooks effectively. The devil is in the details. Happy hacking!
If any of the information above is incorrect, please let me know at [email protected], and I'll make the necessary changes.