Idiomatic Redux: The Tao of Redux, Part 1 - Implementation and Intent

This is a post in the Idiomatic Redux series.


Thoughts on what Redux requires, how Redux is intended to be used, and what is possible with Redux

Intro πŸ”—︎

I've spent a lot of time discussing Redux usage patterns online, whether it be helping answer questions from learners in the Reactiflux channels, debating possible changes to the Redux library APIs on Github, or discussing various aspects of Redux in comment threads on Reddit and HN. Over time, I've developed my own opinions about what constitutes good, idiomatic Redux code, and I'd like to share some of those thoughts. Despite my status as a Redux maintainer, these are just opinions, but I'd like to think they're pretty good approaches to follow :)

Redux is, at its core, an incredibly simple pattern. It saves a current value, runs a single function to update that value when needed, and notifies any subscribers that something has changed.

Despite that simplicity, or perhaps because of it, there's a wide variety of approaches, opinions, and attitudes about how to use Redux. Many of these approaches diverge widely from the concepts and examples that are in the docs.

At the same time, there's been ongoing complaints about how Redux "forces" you to do things certain ways. Many of the complaints actually involve concepts related to how Redux is typically used, rather than any actual limitation imposed by the Redux library itself. (For example, in one recent HN thread alone, I saw complaints about "too much boilerplate", "action constants and action creators aren't needed", "I have to edit too many files to add a feature", "why do I have to switch files to get to my write logic?", "the terms and names are too hard to learn or are confusing", and way too much more.)

As I've researched, read, discussed, and examined the variety of ways that Redux is used and the ideas being shared in the community, I've concluded that it's important to distinguish between how Redux actually works, the ways that Redux is intended to be used conceptually, and the nearly infinite number of ways that it's possible to use Redux. I'd like to address several aspects of Redux usage, and discuss how they fit into these categories. Overall, I hope to explain why specific Redux usage patterns and practices exist, the philosophy and intent behind Redux, and what I consider to be "idiomatic" and "non-idiomatic" Redux usage.

This post will be split into two parts. In Part 1 - Implementation and Intent, we'll look at the actual implementation of Redux, what specific limitations and constraints it requires, and why those limitations exist. Then, we'll review the original intent and design goals for Redux, based on the discussions and statements from the authors (especially during the early development process).

In Part 2 - Practice and Philosophy, we'll investigate the common practices that are widely used in Redux apps, and describe why those practices exist in the first place . Finally, we'll examine a number of "alternative" approaches for using Redux, and discuss why many of them are possible but not necessarily "idiomatic".

Table of Contents πŸ”—︎

Laying the Foundation πŸ”—︎

Examining the Three Principles πŸ”—︎

Let's start by taking a look at the now-famous Three Principles of Redux:

  • Single source of truth: The state of your whole application is stored in an object tree within a single store.
  • State is read-only: The only way to change the state is to emit an action, an object describing what happened.
  • Changes are made with pure functions: To specify how the state tree is transformed by actions, you write pure reducers.

In a very real sense, each one of those statements is a lie! (Or, to borrow the classic line from Return of the Jedi, "they're true... from a certain point of view.")

  • "Single source of truth" is wrong, because (per the Redux FAQ), you don't have to put everything into Redux, the store state doesn't have to be an object, and you don't even have to have a single store.
  • "State is read-only" is wrong, because there's nothing that actually prevents the rest of the application from modifying the current state tree.
  • And "Changes are made by pure functions" is wrong, because the reducer functions could also mutate the state tree directly, or kick off other side effects.

So, if these statements aren't entirely true, why even have them? These principles aren't fixed rules or literal statements about the implementation of Redux. Rather, they form a statement of intent about how Redux should be used.

That theme is going to continue throughout the rest of this discussion. Because Redux is such a minimal library implementation-wise, there's very little that it actually requires or enforces at the technical level. That brings up a valuable side discussion that's worth looking at.

"Language" and "Meta Language" πŸ”—︎

In Cheng Lou's ReactConf 2017 talk on "Taming the Meta Language", he described how only source code is "language", and everything else, like comments, tests, docs, tutorials, blog posts, and conferences, is "meta language". In other words, the source code itself can only convey a certain amount of information by itself. Many additional layers of human-level information passing are needed in order to help people understand the "language".

Cheng Lou's talk then continues on to discuss how moving additional concepts into the actual programming language itself enable expressing more information through the medium of the source code, without having to resort to the use of "meta language" to pass on the ideas. From that perspective, Redux is a tiny "language", and almost all of the information about how it should be used is actually "meta language".

The "language" (in this case, the core Redux library) has minimal expressivity, and therefore the concepts, norms, and ideas that surround Redux are all at the "meta language" level. (In fact, the post Understanding "Taming the Meta Language", which breaks down the ideas in Cheng Lou's talk, actually calls out Redux as a specific example of these ideas.) Ultimately, what this means is that understanding why certain practices exist around Redux, and decisions of what is and isn't "idiomatic", will involve opinions and discussions rather than just determination based on the source code.

How Redux Actually Works πŸ”—︎

Before we really get much further into the philosophical side of things, it's important to understand what kind of technical expectations Redux does actually have. Taking a look at the internals and implementation is informative.

The Core of Redux: createStore πŸ”—︎

The createStore function is the core of Redux's functionality. If we strip out the comments, the error checking, and the code for a couple of advanced features like store enhancers and observables, here's what createStore looks like (code sample borrowed from a "build-a-mini-Redux" tutorial called Hacking Redux ):

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

    function getState() {
        return state
    }
    
    function subscribe(listener) {
        listeners.push(listener)
        return function unsubscribe() {
            var index = listeners.indexOf(listener)
            listeners.splice(index, 1)
        }
    }
    
    function dispatch(action) {
        state = reducer(state, action)
        listeners.forEach(listener => listener())
    }

    dispatch({})

    return { dispatch, subscribe, getState }
}

That's approximately 25 lines of code, yet it includes the key functionality. It tracks the current state value and multiple subscribers, updates the value and notifies subscribers when an action is dispatched, and exposes the store API.

Consider all the things this snippet doesn't include:

  • Immutability
  • "Pure functions"
  • Middleware
  • Normalization
  • Selectors
  • Thunks
  • Sagas
  • Whether action types should be strings or Symbols, and whether they should be defined as constants or written inline
  • Whether you should use action creators to construct those actions
  • Whether the store should contain non-serializable items like promises or class instances
  • Whether the data should be stored normalized or nested
  • Where the async logic should live

In that vein, it's worth quoting Dan Abramov's pull request for the "counter-vanilla" example:

The new Counter Vanilla example is aimed to dispel the myth that Redux requires Webpack, React, hot reloading, sagas, action creators, constants, Babel, npm, CSS modules, decorators, fluent Latin, an Egghead subscription, a PhD, or an Exceeds Expectations O.W.L. level. Nope, it's just HTML, some artisanal script tags, and plain old DOM manipulation. Enjoy!

The dispatch function inside of createStore simply calls the reducer function and saves whatever value it returns. And yet, despite that, the items in this list of ideas are widely acknowledged to be concepts that a good Redux app should care about.

Having listed all the things that createStore does not care about, it's important to note what it does actually require. The real createStore function enforces two specific limitations: actions that reach the store must be plain objects, and actions must have a type field that is not undefined.

Both of these constraints have their origins in the original "Flux Architecture" concept. To quote the Flux Actions and the Dispatcher section of the Flux docs:

When new data enters the system, whether through a person interacting with the application or through a web api call, that data is packaged into an action β€” an object literal containing the new fields of data and a specific action type. We often create a library of helper methods called ActionCreators that not only create the action object, but also pass the action to the dispatcher.

Different actions are identified by a type attribute. When all of the stores receive the action, they typically use this attribute to determine if and how they should respond to it. In a Flux application, both stores and views control themselves; they are not acted upon by external objects. Actions flow into the stores through the callbacks they define and register, not through setter methods.

Redux did not originally require the type field specifically, but the validation check was later added to help catch possible typos or wrong imports of action constants, and to avoid bikeshedding regarding the basic structure of action objects.

The Built-In Utility: combineReducers πŸ”—︎

This is where we start seeing some constraints that more people are familiar with. combineReducers expects that each slice reducer it's been given will "correctly" respond to an unknown action by returning its default state, and never actually return undefined. It also expects that the current state value is a plain JS object, and that there's an exact correspondence between the keys in the current state object and the reducer functions object. Finally, it does reference equality comparisons to see if all slice reducers returned their previous values. If all of the returned values appear to be the same, it assumes that nothing actually changed anywhere, and it returns the original root state object as a potential optimization.

The Original Selling Point: Redux DevTools πŸ”—︎

The Redux DevTools consists of two main pieces: the store enhancer that implements the time traveling behavior by tracking a list of dispatched actions, and the UI that allows you to view and manipulate the history. The store enhancer itself does not care about the contents of the actions or the state - it just stores the actions in memory. The original DevTools UI is a component you render inside of your application's component tree, and it also does not care about the contents of your actions or state. However, the Redux DevTools Extension operates in a separate process (at least under Chrome), and thus requires all actions and state to be serializable in order for all time-traveling features to behave properly and performantly. The ability to import and export state and actions also requires them to be serializable as well.

The other semi-requirement for time travel debugging is immutability and pure functions. If a reducer function mutates state, then jumping between actions in the debugger will result in inconsistent values. If a reducer has side effects, then those side effects will be re-executed each time the action is replayed by the DevTools. In either case, time-travel debugging won't fully work as expected.

The Main UI Bindings: React-Redux and connect πŸ”—︎

React-Redux's connect function is where mutation really becomes an issue. The wrapper components generated by connect implement a lot of optimizations to ensure that the wrapped components only re-render when actually necessary. Those optimizations revolve around reference equality checks to determine if data has actually changed.

Specifically, every time an action is dispatched and subscribers are notified, connect checks to see if the root state object has changed. If it hasn't, connect assumes that nothing else in the state changed, and skips any further rendering work. (This is why combineReducers tries to return the same root state object if possible.) If the root state object did change, connect will call the supplied mapStateToProps function, and do a shallow equality check on the current result versus the previous returned result, to see if any of the props from store data have changed. Again, if the contents of the data appears to be the same, connect will not actually re-render the wrapped component. These equality checks in connect are why accidental state mutations result in components not re-rendering, because connect assumes that data hasn't changed and re-rendering isn't needed.

Commonly Paired Libraries: React and Reselect πŸ”—︎

Immutability comes into play in other libraries that are commonly used with Redux as well. The Reselect library creates memoized "selector" functions that are typically used to extract data from the Redux state tree. Memoization normally relies on reference equality checks to determine if the input parameters are the same.

Similarly, while a React component can implement shouldComponentUpdate using any logic it wants, the most common implementation relies on shallow equality checks of the current props and incoming props, such as return !shallowEqual(this.props, nextProps).

In either case, mutation of data would generally result in undesired behavior. Memoized selectors would likely not return the proper values, and optimized React components would not re-render when they actually should.

Summarizing Redux's Technical Requirements πŸ”—︎

The core Redux createStore function itself puts only two limitations on how you must write your code: actions must be plain objects, and they must contain a defined type field. It does not care about immutability, serializability, or side effects, or what the value of the type field actually is.

That said, the commonly used pieces around that core, including the Redux DevTools, React-Redux, React, and Reselect, do rely on proper use of immutability, serializable actions/state, and pure reducer functions. The main application logic may work okay if these expectations are ignored, but it's very likely that time-travel debugging and component re-rendering will break. These also will affect any other persistence-related use cases as well.

It's also important to note that immutability, serializability, and pure functions are not enforced in any way by Redux. It's entirely possible for a reducer function to mutate its state or trigger an AJAX call. It's entirely possible for any other part of the application to call getState() and modify the contents of the state tree directly. It's entirely possible to put promises, functions, Symbols, class instances, or other non-serializable values into actions or the state tree. You are not supposed to do any of those things, but it's possible.

The Intent and Design of Redux πŸ”—︎

With those technical constraints in mind, we can turn our attention to how Redux is intended to be used. To better understand that intent, it's helpful to look back at the ideas and influences that drove the initial development of Redux.

Redux's Influences and Goals πŸ”—︎

The "Introduction" section in the Redux docs lays out several major influences on Redux's development and concepts in the Motivation, Core Concepts, and Prior Art topics. As a quick summary:

  • Redux's primary goal is to make state mutations predictable by imposing restrictions on how and when updates can happen. It borrows the "Flux architecture" idea of separating update logic from the rest of the application, and using plain object "actions" to describe the changes that need to occur.
  • Development experience features like time travel debugging are intended as a key use case for Redux. Therefore, constraints like immutability and serializability largely exist to make those development use cases possible, as well as making it easier for developers to trace data flow and update logic.
  • Redux wants all actual state update logic to be synchronous, and wants asynchronous behavior kept separate from the act of updating the state.
  • The Flux architecture proposed having multiple individual "stores" for different types of data. Redux combines those multiple "stores" into a single state tree to make debugging, state persistence, and features like undo/redo easier to work with.
  • The single root reducer function can itself be made up of many smaller reducer functions. This allows explicit control of how data is handled, including dependency ordering where updating one slice of state requires another slice to be calculated first, rather than relying on mechanisms like Flux's store.waitFor() event emitter to set up dependency chains.

It's also worth taking a look at the stated design goals from an early version of the Redux README

Philosophy & Design Goals

  • You shouldn't need a book on functional programming to use Redux.
  • Everything (Stores, Action Creators, configuration) is hot reloadable.
  • Preserves the benefits of Flux, but adds other nice properties thanks to its functional nature.
  • Prevents some of the anti-patterns common in Flux code.
  • Works great in isomorphic apps because it doesn't use singletons and the data can be rehydrated.
  • Doesn't care how you store your data: you may use JS objects, arrays, ImmutableJS, etc.
  • Under the hood, it keeps all your data in a tree, but you don't need to think about it.
  • Lets you efficiently subscribe to finer-grained updates than individual Stores.
  • Provides hooks for powerful devtools (e.g. time travel, record/replay) to be implementable without user buy-in.
  • Provides extension points so it's easy to support promises or generate constants outside the core.
  • No wrapper calls in your stores and actions. Your stuff is your stuff.
  • It's super easy to test things in isolation without mocks.
  • You can use β€œflat” Stores, or compose and reuse Stores just like you compose Components.
  • The API surface area is minimal.
  • Have I mentioned hot reloading yet?

Design Principles and Intent πŸ”—︎

Reading through the Redux docs, the early Redux issue threads, and many other comments made by Dan Abramov and Andrew Clark elsewhere, we can see several specific themes regarding the intended design and use of Redux.

Redux Was Built As A Flux Architecture Implementation πŸ”—︎

Redux was originally intended to be "just" another library that implemented the Flux Architecture. As a result, it inherited many concepts from Flux: the idea of "dispatching actions", that actions are plain objects with a type field, the use of "action creator functions" to create those action objects, that "update logic" should be decoupled from the rest of the application and centralized, and more.

I frequently see questions asking "Why does Redux do $THING?", and for many of those questions the answer is "Because that's how the Flux Architecture and specific Flux libraries did things".

State Update Maintainability Is The Main Priority πŸ”—︎

Almost every aspect of Redux is meant to make it easier for a developer to understand when, why, and how a given piece of state changed. That includes both actual implementation as well as encouraged usage.

That means that a developer should be able to look at a dispatched action, see what state changes occurred as a result, and trace back to the places in the codebase where that action is dispatched (especially based on the type of the action). If data is wrong in the Redux store, it should be possible to trace what dispatched action resulted in that wrong state, and work backwards from there.

The emphasis on "hot reloading" and "time-travel debugging" is also aimed squarely at developer productivity and maintainability, since both of those allow a developer to iterate faster and better understand what's happening in the system.

Action History Should Have Semantic Meaning πŸ”—︎

While Redux's core does not care what the actual value of your action's type field is, the clear intent is that action types should have some kind of meaning and information. The Redux DevTools and other logging utilities display the type field for each dispatched action, so having values that are understandable at a quick glance is important.

This means that strings are more useful than Symbols or numbers in terms of conveying information. It also means that the wording of those action type strings should be clear and understandable. This generally means that having more distinct action types is going to be better for developer understanding than only having one or two action types. If only a single action type is used across the entire codebase (like SET_DATA), it will be harder to track down where a particular action was dispatched from, and the history log will be less readable.

Redux Is Intended To Introduce Functional Programming Principles πŸ”—︎

Redux is explicitly intended to be built and used with functional programming concepts, and to help introduce those concepts to both new and experienced developers. This includes FP basics such as immutability and pure functions, but also ideas such as composing functions together to achieve a larger task.

At the same time, Redux is intended to help provide real value to developers trying to solve problems and build applications, without overwhelming a user in too many abstract FP concepts or getting bogged down in arguments over deep FP terminology like "monads" or "endofunctors". (Admittedly, the number of terms and concepts around Redux has grown over time, and many of those are confusing to new learners, but the goals of leveraging the benefits of FP and introducing learners to FP were clearly part of the original design and philosophy.)

Redux Promotes Testable Code πŸ”—︎

Having reducers be pure functions enables time travel debugging, but it also means that a reducer function should be easily testable in isolation. Testing a reducer should only require calling it with specific arguments, and verifying the output - no need to mock things like AJAX calls.

AJAX calls and other side effects still have to live somewhere in the application, and testing code that uses those can still take work. However, emphasizing pure functions for a meaningful part of the codebase reduces the overall complexity of testing.

Reducer Functions Should Be Organized By State Slice πŸ”—︎

Redux takes the concept of individual "stores" from the Flux architecture, and merges them into a single combined store. The most straightforward mapping between Flux and Redux is to create a separate top-level key or "slice" in the state tree for each store. If a Flux app has a separate UsersStore, PostsStore, and CommentsStore, the Redux equivalent would probably have a root state tree that looks like {users, posts, comments}.

It's possible to have a single function that contains all the logic for updating all of those state slices together, but any meaningful application will want to break up that function into smaller functions for maintainability. The most obvious way to do that is to split up the logic based on which slice of state needs to be updated. This means that each "slice reducer" only needs to worry about its own slice of state, and as far as it knows, that slice may as well be all of the state. This pattern of "reducer composition" can be nested repeatedly to handle updates to nested state structure, and the combineReducers utility is included with Redux specifically to make it easy to follow this pattern.

If each slice reducer function can be called separately and given just its own slice of state as a parameter, that also implies that multiple slice reducers can be called with the same action, and each one can update its own slice of state independently from any others. Based on statements from Dan and Andrew, having a single action result in updates from multiple slice reducers is a core intended use case for Redux. This is often referred to as actions having a "1:many" relationship with reducer functions.

Update Logic And Data Flow Are Explicit πŸ”—︎

Redux does not contain any "magic". A few aspects of its implementation are a bit tricky to grasp right away unless you're familiar with some more advanced FP principles (such as applyMiddleware and store enhancers), but otherwise everything is intended to be explicit, clear, and traceable, with minimal abstraction.

Redux really doesn't even implement the actual state update logic. It simply relies on whatever root reducer function you provide. It does provide the combineReducers utility to help with the intended common use case of slice reducers managing state independently, but you are entirely encouraged to write your own reducer logic to handle your own needs. This also means that your reducer logic can be simple or complex, abstracted or verbose - it's all about how you want to write it.

In the original Flux dispatcher, Stores needed a waitFor() event that could be used to set up dependency chains. If a CommentsStore needed data from a PostsStore to properly update itself, it could call PostsStore.waitFor() to ensure that it would run after the PostsStore updated. Unfortunately, that dependency chain was not easily visualized. However, with Redux, that sequencing can simply be accomplished by explicitly calling specific reducer functions in sequence.

As an example, here's some (slightly modified) quotes and snippets from Dan's "Combining Stateless Stores" gist:

In this case commentsReducer no longer truly depends on the state and action. It also depends on hasCommentReallyBeenAdded.

We add this parameter to its API. Sure, it's no longer usable β€œas is”, but that's the point: it has an explicit dependency now on other data. It's not a top-level store. Whoever manages it must somehow give it that data.

export default function commentsReducer(state = initialState, action, hasPostReallyBeenAdded) {}

// elsewhere
export default function rootReducer(state = initialState, action) {
  const postState = postsReducer(state.post, action);
  const {hasPostReallyBeenAdded} = postState;
  const commentState  = commentsReducer(state.comments, action, hasPostReallyBeenAdded);
  return { post : postState, comments : commentState };
}

This also applies to the idea of "higher order reducers". A given slice reducer can be wrapped up in other reducers to add abilities like undo/redo or pagination.

Redux's API Should Be Minimal πŸ”—︎

This goal was stated repeatedly by both Dan and Andrew throughout Redux's development. It's easiest to just quote some of their comments:

Andrew - #195:

The best API is often no API. The current proposals for middleware and higher-order stores have the tremendous benefit that they require no special treatment by the Redux core β€” they're just wrappers around dispatch() and createStore(), respectively. You can even use them today, before 1.0 is released. That's a huge win for extensibility and rapid innovation. We should favor patterns and conventions over rigid, privileged APIs.

Dan - #216:

Here's why I chose to write Redux instead of using NuclearJS:

  • I don't want a hard dependency on ImmutableJS
  • I want as little API as possible
  • I want to make it easy to jump off Redux when something better comes around

With Redux, I can use plain objects, arrays and whatnot for the state.

I tried hard to avoid APIs like createStore because they bind you to a particular implementation. Instead, for each entity (Reducer, Action Creator) I tried to find the minimal way to expose it without having any dependency on Redux whatsoever. The only code importing Redux and actually depending hard on it will be in your root component and the components that subscribe to it.

Redux Should Be As Extensible As Possible πŸ”—︎

This ties in with the "minimal API" goal. Some Flux libraries, like Andrew's Flummox lib, had some form of async behavior built-in to the library itself (such as dispatching START/SUCCESS/FAILURE actions for promises). However, while having something built into the core meant it was always available, it also limited flexibility.

Again, it's easiest to quote comments from the design discussions and the Hashnode AMA with Dan and Andrew :

Andrew - #55:

To continue supporting async actions, and to provide an extensibility point for external plugins and tools, we can provide some common action middleware, a helper for composing middleware, and documentation for how extension authors can easily create their own.

Andrew - #215:

I agree it's a natural feature that most Redux apps will probably want to have, but once we put it into the core, everyone will start bikeshedding exactly how it should work, which is what happened for me with Flummox. We're trying to keep the core as minimal and flexible as possible so we can iterate quickly and allow others to build on top of it.

As Dan said once, (I can't remember where... probably Slack) we're aiming to be like the Koa of Flux libraries. Eventually, once the community is more mature, the plan is to maintain a collection of "blessed" plugins and extensions, possibly under a reduxjs GitHub organization.

Dan - Hashnode AMA:

We didn’t want to prescribe something like this in Redux itself because we know a lot of people are not comfortable with learning Rx operators to do basic async stuff. It’s beneficial when your async logic is complex, but we didn’t really want to force every Redux user to learn Rx, so we intentionally kept middleware more flexible.

Andrew - Hashnode AMA:

[the] reason the middleware API exists in the first place is because we explicitly did not want to prescribe a particular solution for async." My previous Flux library, Flummox, had what was essentially a promise middleware built in. It was convenient for some, but because it was built in, you couldn't change or opt-out of its behavior. With Redux, we knew that the community would come up with a multitude of better async solutions that whatever we could have built in ourselves.

Redux Thunk is promoted in the docs because it's the absolute bare minimum solution. We were confident that the community would come up with something different and/or better. We were right!

Final Thoughts πŸ”—︎

I spent a lot of time doing research for these two posts. It was fascinating to read back through the early issues and discussions, and watch Redux evolve into what we now know. As seen in that quoted README, the vision for Redux was clear from the beginning, and there were several specific insights and conceptual leaps that resulted in the final API and implementation. Hopefully this look at the internals and the history of Redux helps shed some light on how Redux actually works, and why it was built this way.

Be sure to check out The Tao of Redux, Part 2 - Practice and Philosophy, where we'll look at why many common patterns of Redux usage exist, and I'll give my thoughts on the pros and cons of many "variations" in how it's possible to use Redux.

Further Information πŸ”—︎


This is a post in the Idiomatic Redux series. Other posts in this series: