Skip to main content

Redux

Free2017-06-24#Front-End#JS#Redux入门#Redux guide#Redux与Flux#Flux与Redux#Redux vs Flux

From Flux to Redux

1. Purpose

Like Flux, it serves as a state management layer that enforces strong constraints on unidirectional data flow.

2. Motivation

In MVC, there are clear boundaries between data (Model), presentation (View), and logic (Controller), but data flow is bidirectional, which is particularly evident in large-scale applications. A change (user input or internal API call) can affect the state in multiple parts of the application—as seen with two-way data binding—making maintenance and debugging difficult.

If one model can update another model, a view updating a model could lead to that model updating another, potentially triggering another view update. It becomes unclear what is happening in the application at any given moment because it's unknown when, why, and how state changes occur. The system becomes opaque, making it hard to reproduce bugs and add new features.

The goal is to reduce complexity and improve maintainability and code predictability by enforcing unidirectional data flow.

3. Core Concepts

Redux maintains the state of the entire application in a single immutable state tree. It cannot be changed directly; when changes occur, new objects are created via actions and reducers. Specifically:

  • The application's state object has no setters and cannot be modified directly.

  • State is modified by dispatching actions.

  • Reducers connect actions and state.

  • Higher-level reducers organize lower-level ones to form a reducer tree, calculating the state level by level.

Functional reducers are key:

  • Small (single responsibility).

  • Pure (no side effects, no impact on the environment).

  • Independent (not environment-dependent; fixed input yields fixed output. Easy to test, only needing to focus on whether the return value for a given input is correct).

Pure function constraints enable powerful debugging features (otherwise state rollbacks would be nearly impossible), allowing precise tracking of changes via DevTools:

  • Displaying current state, historical actions, and their corresponding states.

  • Skipping certain actions to quickly assemble bug scenarios without manual preparation.

  • State Reset, Commit, and Revert.

  • Hot loading to locate reducer issues and apply fixes immediately.

4. Structure

action  Like in Flux, these are events with a type and data (payload).
        Similarly, actions are dispatched manually.
---
store   Has the same function as in Flux, but there is only one globally.
        Implemented as an immutable state tree.
        Distributes actions and registers listeners. Each action passes through layers of reducers to yield a new state.
---
reducer Similar to arr.reduce(callback, [initialValue]).
        The reducer acts as the callback, taking current state and an action as input and outputting a new state.

The concept of a reducer is similar to Node.js middleware or Gulp plugins. Each reducer is responsible for a small part of the state tree. A series of reducers are chained together (the output of the previous reducer serves as the input for the current one) to produce the final output state.

Every modification to the state by a reducer creates a new state object; old values point to the original reference, while new values are created anew.

Strict unidirectional data flow:

                  call             new state
action --> store ------> reducers -----------> view

Actions are also handed to all top-level reducers (similar to Flux), flowing down to the appropriate subtrees.

The store coordinates the process: it passes the action and current state to the reducer tree to get the new state, updates the current state, and then notifies the view to update (in React, this would be setState()).

action

Actions describe what happened (like a news headline).

Action and action creator correspond to the traditional event and createEvent(). Action creators are used for portability and testability.

The architectural separation of action creator and store considers server-side rendering, where each request corresponds to an independent store, with the binding of action creator and store performed externally.

Note: In practice, creating an action and dispatching it should be decoupled. In scenarios where this is needed (e.g., passing to a child component while hiding the dispatch), Redux provides bindActionCreators to bind them together.

Additionally, consider asynchronous scenarios:

  • Number of actions

    An asynchronous operation might require three actions (or one action with three states): Start/Success/Failure. These correspond to UI states: show loading / hide loading and show new data / hide loading and show error message.

  • Timing of view updates

    After an asynchronous operation completes, dispatch an action to modify the state and update the view.

    There is no need to worry about the timing of multiple asynchronous operations because, from the action history, the sequence is fixed regardless of whether the dispatch occurred during a synchronous or asynchronous process.

This is not much different from synchronous scenarios, except there are more actions. Certain middleware (redux-thunk, redux-promise, etc.) just make asynchronous control look more elegant but are no different from the perspective of dispatching actions.

reducer

Responsible for specific state updates (updating state based on actions, turning the action's description into reality).

Compared to Flux, Redux uses pure function reducers instead of event emitters:

  • Decomposition and Composition

    State is decomposed by splitting reducers, which are then combined (using the combineReducers() utility function) to form a state tree. Reducer composition is a common pattern (standard routine) in Redux applications.

    Typically, one reducer is split into a group of similar reducers (or a reducer factory is abstracted).

  • Single Responsibility

    Each reducer is responsible only for a portion of the global state.

Specific constraints for a pure function reducer (consistent with the concept of pure functions in FP) are as follows:

  • Do not modify arguments.

  • Perform pure calculations only; do not include side effects like routing changes or other API calls.

  • Do not call impure methods (where output depends on environment as well as input), such as Math.random() or new Date().

Furthermore, reducers are closely tied to state. Since state is the result of the reducer tree's calculations, the entire application's state structure must be planned first. Here are some very useful tips:

  • Split state into data state and UI state.

    UI state can be maintained within components or attached to the state tree, but distinguishing between data state and UI state is always worth considering.

    (Simple scenarios and UI state changes might not need to be part of the store and should be maintained at the component level instead.)

  • Treat state as a database.

    For complex applications, treat the state as a database. Index data when storing it, and use IDs to reference related data. This keeps things independent and reduces nested states (nested states make state subtrees larger, whereas a data table + relationship table approach does not).

Store

The glue that organizes actions and reducers and supports listeners.

Responsible for three things:

  • Holding the state and supporting read/write access (getState() to read, dispatch(action) to write).

  • Scheduling the reducer when an action is received.

  • Registering/unregistering listeners (triggered on every state change).

5. Three Basic Principles

The entire application corresponds to a single state tree

This makes it very easy to generate another state (keeping historical versions) and implement redo/undo.

State is read-only

  • State can only be updated by triggering an action.

  • Changes are centralized and occur in a strict sequence (no race conditions to worry about).

  • Since actions are plain objects, they can be logged, serialized, stored, and replayed later (for debugging/testing).

Reducers are pure functions

They take state and an action as input and output a new state. They always return a new object without maintaining (modifying) the input state.

Thus, reducer execution order can be adjusted at will, enabling movie-like debugging controls.

6. react-redux

Redux has no relationship with React; as a state management layer, it can be used with any UI solution, such as Backbone, Angular, React, etc.

react-redux handles the new state -> view part. That is, once there is a new state, how is the view synchronized?

container

It also uses the concepts of container and view (same as Flux).

A container is a special type of component without view logic that is closely tied to the store. Logically, it reads a portion of the state tree via store.subscribe() and passes it as props to the ordinary components (view) below.

connect()

A seemingly magical API that primarily does three things:

  • Injects dispatch and state data as props into the ordinary components below.

  • Automatically inserts containers into the Virtual DOM tree.

  • Includes built-in performance optimizations to avoid unnecessary updates (built-in shouldComponentUpdate).

7. Redux vs. Flux

Similarities

  • Both extract Model update logic into a separate layer (Redux's reducer, Flux's store).

  • Neither allows direct model updates, requiring an action to describe every change.

  • The basic approach of (state, action) => state is consistent.

Differences

  • Redux is a specific implementation, while Flux is a pattern.

    There is only one Redux, whereas Flux has over a dozen implementations.

  • Redux's state is a single tree.

    Redux hangs the application state on one tree, with only one global store.

    Flux has multiple stores and broadcasts state changes as events; components sync their current state by subscribing to these events.

  • Redux has no concept of a dispatcher.

    Because it relies on pure functions instead of event emitters. Pure functions can be combined freely without needing to manage sequence explicitly.

    In Flux, the dispatcher is responsible for passing actions to all stores.

  • Redux assumes state is not manually modified.

    A moral constraint; modifying state within a reducer is not allowed (new properties can be added, but existing ones cannot be modified).

    Not making this a hard constraint considers certain performance scenarios; technically, it can be bypassed by writing impure reducers.

    However, if a reducer is impure, the powerful debugging features relying on pure function composition will be broken, so this is strongly discouraged.

    The reason state is not forced to use immutable data structures is for performance (extra overhead related to immutability) and flexibility (it can be used with const, immutablejs, etc.).

8. Questions and Reflections

1. How is granularity controlled in the state change subscription mechanism?

Since subscribe(listener) only yields the complete global state, what is the granularity of React's setState()? How are subtrees handled?

It's handled manually. Any change to the state tree notifies all listeners, which then manually determine if the specific part of the state they care about has changed. In other words, the subscription mechanism doesn't handle distribution; manual distribution is required.

2. What's the deal with react-redux's <Provider>?

I'll guess it's some black magic performed via hostContainerInfo. (Guessed wrong.) It requires Provider to be the top-level container when rendering root:

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

hostContainerInfo looks like this:

function ReactDOMContainerInfo(topLevelWrapper, node) {
  var info = {
    _topLevelWrapper: topLevelWrapper,
    _idCounter: 1,
    _ownerDocument: node ? node.nodeType === DOC_NODE_TYPE ? node : node.ownerDocument : null,
    _node: node,
    _tag: node ? node.nodeName.toLowerCase() : null,
    _namespaceURI: node ? node.namespaceURI : null
  };
  if ("development" !== 'production') {
    info._ancestorInfo = node ? validateDOMNesting.updatedAncestorInfo(null, info._tag, null) : null;
  }
  return info;
}

(Taken from ReactDOM v15.5.4 source code)

All components on the Virtual DOM tree share hostContainerInfo, so the store is accessible in all containers. For example code, see Usage with React.

Actual implementation of react-redux

I guessed wrong; take a look here.

Internal instances are private properties (a random key, __reactInternalInstance&<random>), so components cannot access hostContainerInfo. However, React provides an enhanced version of hostContainerInfo called context, specifically for scenarios requiring deep manual passing of props. It roughly works like this:

// Provider
class Provider extends React.Component {
    constructor(props) {
        super(props);
    }
    // Use the manually passed store prop as a context property
    getChildContext() {
        return {store: this.props.store};
    }
    render() {
        return this.props.children;
    }
}

// container
class Container extends React.Component {
    // Retrieve the store from context and use it as a container prop
    // The store is then accessible via this.props.store within the container
    getDefaultProps() {
        return {
            store: this.context.store;
        }
    }
}

It works as if the store penetrates from the top level into all components. Technically, ordinary components (view, not container) could also access the store directly via this.context.store (since context is passed down automatically and mindlessly), but doing so would be improper.

P.S. I never knew what context was for; now I finally understand.

3. How are tree scenarios (infinite expansion) handled?

In a typical business scenario involving an infinite tree structure, the trick is to treat state as a database (as mentioned earlier).

According to Redux philosophy, the tree should be flattened into nodes. Coarse granularity could be nodeId - children, while fine granularity would be nodeId - node (where children becomes a childrenIdList, and children are retrieved by looking up the master ID table).

Flattening solves the problem and is much easier to maintain than nested state. If a tree component corresponded to a single tree object (with all nodes on the tree), performing partial updates on a large tree would be very painful.

P.S. I can't believe 3NF can be applied to the front end!

References

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment