What I Learned From Reading the Redux Source Code

As a web developer, I frequently use libraries and frameworks to speed up development and solve common problems. But recently, I decided to go beyond the APIs and documentation and actually read through the source code of a popular library – Redux. Created by Dan Abramov and Andrew Clark, Redux has become the de facto standard for managing state in large React applications.

I‘ve used Redux in several projects and understand the basic concepts, but I wanted to dive deeper. I wanted to see how it really works under the hood, pick up some techniques and ideas, and become a better developer in the process. Redux has a relatively small codebase, weighing in at around 2KB, which made it an approachable learning opportunity.

Here are some of the key insights I gained from my deep dive into the Redux source code:

Pure Functions and Immutability

At the core of Redux are a few key functional programming concepts, namely pure functions and immutability. A pure function always returns the same output for a given input and has no side effects. Immutability means state is never directly modified, only replaced with new versions. This makes state changes predictable and enables useful features like undo/redo and time travel debugging.

Redux wholly embraces pure functions and immutability. The key concepts of actions, action creators, and reducers are all based on pure functions. State is treated as immutable, so reducers must return new state objects rather than mutating previous state. Seeing these concepts applied thoroughly in Redux gave me a greater appreciation for them.

For example, here is the code for applying a root reducer to state:

function combineReducers(reducers) {
  const reducerKeys = Object.keys(reducers)
  const finalReducers = {}

  for (let i = 0; i < reducerKeys.length; i++) {
    const key = reducerKeys[i]

    if (typeof reducers[key] === ‘function‘) {
      finalReducers[key] = reducers[key]
    }
  }

  const finalReducerKeys = Object.keys(finalReducers)

  return function combination(state = {}, action) {
    let hasChanged = false
    const nextState = {}
    for (let i = 0; i < finalReducerKeys.length; i++) {
      const key = finalReducerKeys[i]
      const reducer = finalReducers[key]
      const previousStateForKey = state[key]
      const nextStateForKey = reducer(previousStateForKey, action)

      nextState[key] = nextStateForKey
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    return hasChanged ? nextState : state
  }
}

Note how it doesn‘t mutate the existing state object, but rather creates a new nextState object by calling each reducer and combining the results. This pattern is used throughout Redux to maintain immutability.

Functional Composition and Currying

Reading through Redux exposed me to several functional programming techniques I hadn‘t fully grasped before, particularly function composition and currying.

Function composition is the process of combining multiple functions to produce a new function. Currying transforms a function that takes multiple arguments into a sequence of functions that each take a single argument. Redux leverages these concepts heavily, especially for constructing middleware and store enhancers.

The compose utility is a key part of how Redux composes functions:

export default 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)))
}

This higher-order function takes any number of functions as arguments and composes them together from right to left. The composed functions are called in order, with the output of each function being passed as the argument to the next. It‘s commonly used like this:

const middlewareEnhancer = applyMiddleware(middleware1, middleware2, middleware3)
const composedEnhancers = compose(
  middlewareEnhancer,
  DevToolsExtension.instrument()
)
const store = createStore(rootReducer, initialState, composedEnhancers)

Here compose is used to combine the applyMiddleware enhancer (itself a curried function) with the Redux DevTools enhancer into a single enhancer that wraps the store.

Currying is another functional technique used in Redux. The applyMiddleware function is curried to make it easier to compose with other enhancers:

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch = () => {
      throw new Error(
        ‘Dispatching while constructing your middleware is not allowed. ‘ +
          ‘Other middleware would not be applied to this dispatch.‘
      )
    }

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }

    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

applyMiddleware takes a variable number of middleware functions. It returns a new function that expects a createStore function as an argument. That second function returns yet another function that takes the normal store creation arguments and returns an enhanced version of the store with the middleware applied.

This curried, multi-level function allows applyMiddleware to be composed with other enhancers or called directly. It‘s a complex but powerful pattern.

Seeing composition and currying used so effectively gave me a greater appreciation for how they can produce expressive and reusable code. Understanding these concepts more deeply was a major benefit of exploring the Redux source.

Performance Optimizations

Another area where I learned a lot from Redux‘s source code was performance optimization. Maintaining a fast and responsive user interface is crucial for any application, and Redux employs several strategies to minimize expensive calculations and redraws.

One such optimization is memoization. Memoization caches the results of expensive function calls and returns the cached value when the same inputs occur again. Redux uses memoized selector functions to efficiently compute derived data from the store.

The createSelector utility from the Reselect library (which is commonly used with Redux) creates a memoized selector:

export default function createSelector(
  inputSelectors,
  resultFunc,
  selectorCreator = defaultMemoize
) {
  const selector = selectorCreator(
    resultFunc,
    inputSelectors.length === 1
      ? inputSelectors[0]
      : inputSelectors
  )
  selector.resultFunc = resultFunc
  selector.dependencies = inputSelectors
  return selector
}

This function takes one or more "input selector" functions that extract values from the state tree, a "result function" that computes a derived value from those inputs, and an optional memoization function (defaulting to a simple reference equality check).

It returns a new memoized selector function that only recalculates the derived value if the inputs change. When called repeatedly with the same state, the memoized selector returns the previously computed value rather than running the expensive computation again.

To illustrate, imagine a selector that derives a filtered and sorted list from an array in the state:

const getVisibleTodos = createSelector(
  state => state.todos,
  state => state.visibilityFilter,
  (todos, visibilityFilter) => {
    switch (visibilityFilter) {
      case ‘SHOW_ALL‘:
        return todos
      case ‘SHOW_COMPLETED‘:
        return todos.filter(t => t.completed)    
      case ‘SHOW_ACTIVE‘:
        return todos.filter(t => !t.completed)
    }
  }
)

This selector depends on the todos array and the current visibilityFilter. It only recalculates the filtered list if either of those values changes. Memoization can dramatically improve performance by avoiding unnecessary re-renders and computations.

In contrast, an alternative library like MobX uses a very different approach based on observable values and reactive derived data. While this automates a lot of performance optimizations, the "magic" reactivity makes the flow of data harder to follow in my experience.

Redux also minimizes expensive computations with other techniques like batching updates and throttling subscriber notifications. But I think memoized selectors are the most broadly useful optimization I saw in the Redux source.

Adoption and Ecosystem

As I researched more about Redux for this article, I was impressed by how widely used and influential it has become. Some statistics that demonstrate its popularity:

  • Redux has been downloaded over 55 million times from NPM
  • The Redux repository has over 56,000 stars on GitHub (for context, React has 165,000)
  • There are over 63,000 Stack Overflow questions tagged "redux"
  • Thousands of companies use Redux including Twitter, Instagram, and Uber

So while digging through Redux‘s source code may seem like an academic exercise, it‘s actually a very practical investment given how ubiquitous the library is in modern frontend development.

Part of Redux‘s success is the ecosystem of tools and extensions that have grown around it. Besides bindings for different UI frameworks, there are powerful Redux developer tools that let you inspect dispatched actions, replay state changes, and even "time travel" by jumping to previous states.

There‘s also a huge collection of Redux-related libraries that extend its functionality or provide alternate approaches. Exploring the source code of some of these popular addons like Redux Saga, Redux Toolkit, and Redux Observable could be a great way to build on the lessons learned from Redux itself.

Learning From the Pros

In addition to the code itself, I found great value in the writing and speaking of Redux‘s creators Dan Abramov and Andrew Clark. They have been remarkably dedicated to educating the community with detailed articles, talks, and courses.

Some of my favorite learning resources from them:

There are many insightful quotes from them that highlight the intent behind Redux‘s API design. For example, on the importance of reducers being pure functions:

"With Redux, the state mutations need to be describable as plain objects. This is what lets us record and replay user actions for debugging, or even serialize user actions and attach them to bug reports so that the product developers can replay them to reproduce the bugs." – Dan Abramov

And on why common Redux use cases are now part of React itself:

"Context was fine back when Redux was created, but since then React has evolved to put more emphasis on components and I don‘t think it makes sense to keep Redux around any longer when there‘s a perfectly good state management API built into React itself now." – Andrew Clark

Understanding the reasoning behind design decisions gives you another angle to internalize the lessons embedded in the code.

When Redux Isn‘t Right

While diving deep into Redux‘s design gave me a greater appreciation for its ideas and patterns, it also highlighted scenarios where it may be overkill.

Redux is built on some specific opinions like immutable state, global app-wide state, and deterministic state changes described by plain objects. There are absolutely use cases where those opinions make things more complex instead of simpler.

For apps with highly interconnected state and data changing over time, you may find an observable or reactive model (like MobX or Vue) to be a better fit. Libraries like Recoil may be a better fit for sharing state in a React app without the overhead of Redux‘s actions and reducers. And modern React Context and hooks can cover many basic state management needs.

So while I gained a lot from learning Redux‘s patterns, I also came away with a better sense of its tradeoffs and limitations. No tool is right for every use case. Studying Redux gave me a richer perspective on the broader landscape of state management techniques.

Applying These Lessons

Looking at the bigger picture, a lot of the ideas I absorbed from Redux extend to other codebases. Whether it‘s embracing immutability in your React components, using selector functions to encapsulate state lookups, or adopting some of the DevTools to inspect your app‘s behavior, there are many opportunities to apply these lessons.

I also came away very inspired to continue reading the source code of tools I use. The benefits were even greater than I expected, including:

  • Stronger grasp of core concepts and patterns
  • Exposure to advanced language features and techniques
  • Appreciation for API tradeoffs and design decisions
  • Familiarity with common implementation details
  • Greater self-sufficiency in using and debugging the tool

Some personal examples: after reading the Express source code I became much more comfortable implementing my own Express middleware. Browsing the Next.js source gave me a better mental model for how it structures apps and taught me several advanced webpack optimization tricks.

Of course, there are challenges to reading source code, like unfamiliar patterns or having to step through a complex debugging flow. It can certainly feel overwhelming to crack open a popular library for the first time. Here are a few suggestions to make it more approachable:

  • Start with something small and modular like Redux – a huge framework may be too much at first
  • Read the tests! They are usually much simpler than the implementation and show how the pieces fit together
  • Step through the code in a debugger so you can see the live values and control the execution flow
  • Keep the documentation site open to supplement your understanding
  • Take notes as you go so you can revisit and reinforce key insights

I‘m very glad I took the time to really study Redux‘s source in depth. I came away with so many valuable lessons that have made me a more knowledgeable and capable developer. I hope this article inspires you to crack open the source code of a library you‘re curious about and discover what insights await you. It‘s a powerful way to keep growing and mastering your craft.

Similar Posts