DEV Community

Cover image for Redux under the hood
Romain Trotard
Romain Trotard

Posted on

Redux under the hood

Redux is a state management library used in a lot of projects.
An new library named redux-toolkit has be developed to reduce boilerplate of redux. Give it a try it simplifies a lot the code you make, and with typescript <3
To be easily integrated with React, Angular, ... some bindings libraries exist react-redux, ng-redux, ...

But that is not the subject of this article. I will not explain the best practices on how to use Redux. If you want more explanation on how to use it, you can see the
documentation which is awesome: https://redux.js.org/

In this article we are going to see how to implement a redux library like. Don't be afraid, it's not so complicated.

How is the article built?
We are going to pass on each features of redux, a quick view of what is it need for and then the implementation. Features are:

  • store
  • reducers
  • listeners
  • observables
  • replaceReducers
  • middlewares

Let's get in :)


Store creation

Some context

To create a store, you have to use the method createStore and give it the reducer(s) as first parameter:

import { createStore } from "redux";
import userReducer from "./userReducer";

const store = createStore(userReducer);
Enter fullscreen mode Exit fullscreen mode

With this store created, you can get two methods:

  • getState to get the current state
  • dispatch to dispatch actions wich will be passed to reducers
store.dispatch({
  type: "SET_USERNAME",
  payload: "Bob the Sponge",
});

const state = store.getState();

// Will print 'Bob the Sponge'
console.log(state.userName);
Enter fullscreen mode Exit fullscreen mode

Reducers

A reducer is a pure function, it's the only one which can change the state (sometimes called also store). The first parameter of this method is the
current state and the second one the action to handle:

The action is a simple object which is often represented with:

  • type: the type of the action to process
  • payload: the data useful to process the action
const initialState = { userName: undefined };

export default function userReducer(
  state = initialState,
  action
) {
  switch (action.type) {
    case "SET_USERNAME": {
      // The state must stay immutable
      return { ...state, userName: action.payload };
    }
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Well, Romain, you told us that you will explain what is under the hood and finally you explain how to use it.

Sorry guys, I needed to put some context before going deep into Redux ;)

Under the hood

Note: The implementation I will show you is based on the version 4.1.2.

createStore is a closure which has a state object and returns the methods getState and dispatch:

function createStore(reducer) {
  let state;

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);

    return action;
  };

  // Populates the state with the initial values of reducers
  dispatch({ type: "@@redux/INIT" });

  return { getState, dispatch };
}
Enter fullscreen mode Exit fullscreen mode

Note: As you can see, the data is stored in a simple object and it's executed synchronously.
createStore can receive a preloadedState to initialize the state. It's not useful if you have initial states on your reducers.


Multiple reducers

For the moment, we saw a simple case with a single reducer. But in applications, you usually more than one. Otherwise redux is maybe a little bit overkill for your use case.

Redux can structure the store in a clean way, by dividing our store.

Let's go use the function combineReducers.

For example, with the previous reducer userReducer, and the new one settingsReducer:

const initialState = { maxSessionDuration: undefined };

export default function settingsReducer(
  state = initialState,
  action
) {
  switch (action.type) {
    case "SET_": {
      return {
        ...state,
        maxSessionDuration: action.payload,
      };
    }
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

The combination of reducers will be:

import { combineReducers } from "redux";
import userReducer from "./userReducer";
import settingsReducer from "./settingsReducer";

export default combineReducers({
  user: userReducer,
  settings: settingsReducer,
});
Enter fullscreen mode Exit fullscreen mode

We will get the state:

{
  user: {
    userName: undefined,
  },
  settings: {
    maxSessionDuration: undefined,
  },
}
Enter fullscreen mode Exit fullscreen mode

I will tell you amazing, the code of createStore doesn't change. So how does combineReducers work?

function combineReducers(reducersByNames) {
  return (state, action) => {
    let hasChanged = false;
    const nextState = {};

    Object.entries(reducersByNames).forEach(
      ([reducerName, reducer]) => {
        // A reducer cannot access states of other ones
        const previousReducerState = state[reducerName];

        // Calculate the next state for this reducer
        const nextReducerState = reducer(
          previousReducerState,
          action
        );

        nextState[reducerName] = nextReducerState;

        // Notice the strict equality
        hasChanged =
          hasChanged ||
          nextReducerState !== previousReducerState;
      }
    );

    // If there is no changes, we return the previous state
    // (we keep the reference of the state 
    // for performance's reasons)
    return hasChanged ? nextState : state;
  };
}
Enter fullscreen mode Exit fullscreen mode

Note: We can see bellow that if there is
mutations of the state, then Redux will be completely lost and will not see changes with the strict equality.

Redux state needs to be immutable

Note: In the real code, there are a lot of checks to be sure everything is working correctly. I have simplified the code to explain how it works without superfluous code.

Tip: The action is passed to all reducers, so the reducer has to to check if it knows how to handle the action. So, we can have multiple reducers which are able to handle a same action.

Listeners

What is it?

A listener is a callback we can subscribe to potential changes of the Redux state. This listener is directly executed after an event is dispatched.
Previously I talked about potential changes because, after an action has been dispatched, there is not necessarily changes. For example if none of the reducers know how to handle the event.

Once subscribed, we get a callback to be able to unsubscribe it.

An example of use case

For example if you don't want, or can't use the plugin Redux DevTools. It can be useful to be able to see the Redux state at any time. In this case, you can use a listener:

import { createStore } from "redux";
import userReducer from "./userReducer";

const store = createStore(userReducer);

store.subscribe(
  () => (window.reduxState = store.getState())
);
Enter fullscreen mode Exit fullscreen mode

And now you can see, at any time, the state by typing in your favorite browser's console: reduxState.

Let's see some code

Our createStore becomes:

function createStore(reducer) {
  let state;
  let listeners = [];

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);

    listeners.forEach((listener) => listener());

    return action;
  };

  const subscribe = (listener) => {
    listeners = [...listeners, listener];

    // Returns the `unsubscribe` method
    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  };

  dispatch({ type: "@@redux/INIT" });

  // We now expose the `subscribe` method
  return { getState, dispatch, subscribe };
}
Enter fullscreen mode Exit fullscreen mode

Note: I have simplified a lot the method subscribe. In reality, there are a lot of check, especially:

  • not to be able to subscribe/unsubscribe when an action is dispatched
  • ensure this is a function passed as listener
  • be able to call the unsubscribe mutliple times without errors
  • ...

Observable

Some background

It can be an unknown feature for you, but the store is an Observable, so if you use for example RxJS, you can add an Observer to be notified of state's changes.

import { from } from "rxjs";
import { createStore } from "redux";
import userReducer from "./userReducer";

const store = createStore(userReducer);

const myObserver = {
  next: (newState) =>
    console.log("The new redux state is: ", newState),
};

from(store).subscribe(myObserver);

// Let's change the username
store.dispatch({
  type: "SET_USERNAME",
  payload: "Bob l'éponge",
});
Enter fullscreen mode Exit fullscreen mode

How does it work?

To be an Observable, the store just has to add the Symbol.observable (or @@observable if Symbol.observable is undefined) to its key and implements an observable method.
Its implementation is really simple because it reuses the implementation of listeners:

function createStore(reducer) {
  let state;
  let listeners = [];

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);

    listeners.forEach((listener) => listener());

    return action;
  };

  const subscribe = (listener) => {
    listeners = [...listeners, listener];

    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  };

  const observable = () => ({
    subscribe: (observer) => {
      // The method `observeState` only notifies the Observer
      // of the current value of the state
      function observeState() {
        observer.next(getState());
      }

      // As soon as the Observer subscribes we send the
      // current value of the state
      observeState();

      // We register the `observeState` function as a listener
      // to be notified of next changes of the state
      const unsubscribe = listenerSubscribe(observeState);

      return {
        unsubscribe,
      };
    },
  });

  dispatch({ type: "@@redux/INIT" });

  return {
    getState,
    dispatch,
    subscribe,
    [Symbol.observable]: observable,
  };
}
Enter fullscreen mode Exit fullscreen mode

replaceReducer

Implementation

When you use code splitting, it can happened you do not have all reducers while creating the store. To be able to register new reducers after store
creation, redux give us access to a method named replaceReducer which enables the replacement of reducers by new ones:

function createStore(reducer) {
  let state;
  let listeners = [];

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);

    listeners.forEach((listener) => listener());

    return action;
  };

  const subscribe = (listener) => {
    listeners = [...listeners, listener];

    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  };

  const observable = () => {
    const listenerSubscribe = subscribe;

    return {
      subscribe: (observer) => {
        function observeState() {
          observer.next(getState());
        }

        observeState();

        const unsubscribe = listenerSubscribe(observeState);
        return {
          unsubscribe,
        };
      },
    };
  };

  const replaceReducer = (newReducer) => {
    reducer = newReducer;

    // Like the action `@@redux/INIT`,
    // this one populates the state with 
    // initial values of new reducers
    dispatch({ type: "@@redux/REPLACE" });
  };

  dispatch({ type: "@@redux/INIT" });

  return {
    getState,
    dispatch,
    subscribe,
    [Symbol.observable]: observable,
    replaceReducer,
  };
}
Enter fullscreen mode Exit fullscreen mode

Warning: I didn't tell you previously, but like @@redux/INIT, @@redux/REPLACE should be handled in your reducers. Otherwise you will have problems
with hot reload and your reducers will become unpredictable.

Note: Actually these actions do not have these types, they are suffixed.
React action types

Example of usage

Let's use this new method replaceReducer to register a new reducer. At the store creation we only register the reducer userReducer, then we register the reducer counterReducer:

export default function counterReducer(
  state = { value: 0 },
  action
) {
  switch (action.type) {
    case "INCREMENT": {
      return { ...state, value: state.value + 1 };
    }
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

The replacement of reducers will be:

import { createStore, combineReducers } from "redux";
import userReducer from "userReducer";
import counterReducer from "counterReducer";

const store = createStore(
  combineReducers({ user: userReducer })
);

// Will print { user: { userName: undefined } }
console.log(store.getState());

store.replaceReducer(
  combineReducers({
    user: userReducer,
    counter: counterReducer,
  })
);

// Will print 
// { user: { userName: undefined }, counter: { value: 0 } }
console.log(store.getState());
Enter fullscreen mode Exit fullscreen mode

Note: If in this example, I have subscribed a listener after the store creation, this one would have been triggered after the reducers modification.

Middleware

Presentation

A middleware is a tool that we can put between two applications. In the Redux case, the middleware will be placed between the dispatch call and the
reducer. I talk about a middleware (singular form), but in reality you can put as much middleware as you want.

An example of middleware could be to log dispatched actions and then the new state.

How do we write a middleware?

I'm gonna directly give you the form of a middleware without explanation because I will never do better than the official documentation.

const myMiddleware = (store) => (next) => (action) => {
  // With the store you can get the state with `getState`
  // or the original `dispatch`
  // `next`represents the next dispatch
  return next(action);
};
Enter fullscreen mode Exit fullscreen mode

Example: middleware of the loggerMiddleware

const loggerMiddleware = (store) => (next) => (action) => {
  console.log(`I'm gonna dispatch the action: ${action}`);
  const value = next(action);
  console.log(`New state: ${value}`);
  return value;
};
Enter fullscreen mode Exit fullscreen mode

redux-thunk middleware example

Until now, we dispatched actions synchronously. But in an application it can happened we would like to dispatch actions asynchronously. For example, after having resolved an AJAX call with axios (fetch or another library).

The implementation is really simple, if the action dispatched is a function, it will execute it with getState and dispatch as parameters. And if it's not a function, it passes the action to the next middleware or reducer (if there is no more middleware).

const reduxThunkMiddleware =
  ({ getState, dispatch }) =>
  (next) =>
  (action) => {
    if (typeof action === "function") {
      return action(dispatch, getState);
    }

    return next(action);
  };
Enter fullscreen mode Exit fullscreen mode

The thunk action creator will be:

function thunkActionCreator() {
  return ({ dispatch }) => {
    return axios.get("/my-rest-api").then(({ data }) => {
      dispatch({
        type: "SET_REST_DATA",
        payload: data,
      });
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

Store configuration

Before talking about how to configure middlewares with redux, let's talk about Enhancer. An enhancer (in redux) is in charge of 'overriding' the original behavior of redux. For example if we want to modify how works the dispatch (with middlewares for instance), enrich the state with
extra data, add some methods in the store...

The enhancer is in charge of the creation of the store with the help of the createStore function, then to override the store created. Its signature is:

// We find the signature of the `createStore` method:
// function(reducer, preloadedState) {}
const customEnhancer =
  (createStore) => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState);

    return store;
  };
Enter fullscreen mode Exit fullscreen mode

As you may notice, to use middlewares we need an enhancer which is provided by redux (the only one enhancer provided by redux) which is named applyMiddleware:

// Transform first(second(third))(myInitialValue)
// with compose(first, second, third)(myInitialValue)
function compose(...functions) {
  return functions.reduce(
    (f1, f2) =>
      (...args) =>
        f1(f2(...args))
  );
}

const applyMiddleware =
  (...middlewares) =>
  (createStore) =>
  (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState);

    const restrictedStore = {
      state: store.getState(),
      dispatch: () =>
        console.error(
          "Should not call dispatch while constructing middleware"
        ),
    };
    const chain = middlewares.map((middleware) =>
      middleware(restrictedStore)
    );
    // We rebuild the dispatch with our middlewares
    // and the original dispatch
    const dispatch = compose(chain)(store.dispatch);

    return {
      ...store,
      dispatch,
    };
  };
Enter fullscreen mode Exit fullscreen mode

Note: Perhaps you used to use the method reduce with an accumulator initialized with a second parameter:

const myArray = [];
myArray.reduce((acc, currentValue) => {
  // Do some process
}, initialValue);
Enter fullscreen mode Exit fullscreen mode

If you do not give an initial value (no second parameter), the first value of your array will be taken as the initial value.

Note: Looking at the applyMiddleware implementation, you can notice that the middleware's signature could be (store, next) => action.
You can see these PRs which are about this: PR 784, PR 1744

The createStore becomes:

function createStore(reducer, preloadedState, enhancer) {
  // We can pass the enhancer as 2nd parameter
  // instead of preloadedState
  if (
    typeof preloadedState === "function" &&
    enhancer === undefined
  ) {
    enhancer = preloadedState;
    preloadedState = undefined;
  }

  // If we have an enhancer, let's use it to create the store
  if (typeof enhancer === "function") {
    return enhancer(createStore)(reducer, preloadedState);
  }

  let state = preloadedState;
  let listeners = [];

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);

    listeners.forEach((listener) => listener());

    return action;
  };

  const subscribe = (listener) => {
    listeners = [...listeners, listener];

    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  };

  const observable = () => {
    const listenerSubscribe = subscribe;

    return {
      subscribe: (observer) => {
        function observeState() {
          observer.next(getState());
        }

        observeState();

        const unsubscribe = listenerSubscribe(observeState);
        return {
          unsubscribe,
        };
      },
    };
  };

  const replaceReducer = (newReducer) => {
    reducer = newReducer;

    dispatch({ type: "@@redux/REPLACE" });
  };

  dispatch({ type: "@@redux/INIT" });

  return {
    getState,
    dispatch,
    subscribe,
    [Symbol.observable]: observable,
    replaceReducer,
  };
}
Enter fullscreen mode Exit fullscreen mode

An now we can use our middlewares:

import loggerMiddleware from "./loggerMiddleware";
import { createStore, applyMiddleware } from "redux";
import userReducer from "./userReducer";

// In this case the enhancer is passed as 2nd parameter
const store = createStore(
  userReducer,
  applyMiddleware(loggerMiddleware)
);
Enter fullscreen mode Exit fullscreen mode

Conclusion

As you can see the code of Redux is pretty simple but so much powerful. Data is only stored in an object, and changes are done through reducers.
You can also subscribe to changes, and that's what is done in binding libraries like react-redux.
Keep in mind that Redux has been developped to be synchronous, and if you to handle asynchronous action creator you will have to use a middleware, like redux-thunk or redux-saga.
Due to performance, like for React state, you can't mutate the state, but recreate a new one. If it's too much boilerplate for you, you can give a chance to redux-toolkit which is using immer under the hood, to write simpler code and "mutate" the state.
Watch out, do not use Redux by default, but only if you need it.
If you work with React, you have some other possibilities like:

  • React state
  • React context, probably combined with useState or useReducer (you can see my article on the performance problem you can encounter here)
  • atom state management library like jotai, recoil.
  • async state manager libraries: react-query, swr, ...

Do not hesitate to comment and if you want to see more, you can follow me on Twitter or go to my Website.

Discussion (0)