Redux is a universal front-end state management library. It is widely used not only in React App, but also in Wepy, Flutter and other frameworks. Redux is very popular among people who like Functional Programming. Today I’m going to talk to you about Redux’s basic ideas.

Flux

Flux is the basic architecture used by Facebook to build client-side Web applications. We can think of Flux as a design pattern for data flow in applications, and Redux is a set of solutions based on the core ideas of Flux, which has also been affirmed by the original author.

First, there are the following characters in Flux:

  • Dispacher: Scheduler that receives actions and sends them to the Store.
  • Action: Indicates the Action message, including the Action type and Action description.
  • Store: Data center that holds application data and responds to Action messages.
  • View: Displays Store data and responds to Store updates in real time.

From the point of View of communication it can also be considered as Action request layer -> Dispatcher transport layer -> Store processing layer -> View View layer.

Unidirectional data flow

Flux application data flows in a single direction:

  1. The view generates an action message that passes the action to the scheduler.
  2. The scheduler sends action messages to each data center.
  3. The data center then passes the data to the view.

Single-direction data flow also has the following characteristics:

  • Centrally manage data. Whereas a regular application might change and Store data state anywhere in the view layer or at callbacks, in the Flux architecture all data is stored and managed only in the Store.
  • Predictability. In two-way binding or responsive programming, when one object changes, it is possible to cause another object to change, triggering multiple cascading updates. In the case of the Flux architecture, one Action triggers only one data flow cycle, which makes the data more predictable.
  • Easy to track changes. All causes of data changes can be described by an Action, which is a pure object and therefore easy to serialize or view.

Flux workflow

From the above section, we have a general idea of the roles in Flux. Now let’s explain how they form a workflow with a simple code example:

There is an Action Creator concept in the image above, they are actually used to assist in creating Action objects and passing them to the Dispatcher:

function addTodo(desc) {
  const action = {
    type: 'ADD_TODO',
    payload: {
      id: Date.now(),
      done: false,
      desciption: desc
    }
  }
  dispatcher(action)
}
Copy the code

Here I want to make a simple description in code form, which is more intuitive. First, initialize a project:

mkdir flux-demo && cd flux-demo
npm init -y && npm i react flux
touch index.js
Copy the code

Then we create a Dispatcher object, which is essentially an event system in the Flux system that triggers events and response callbacks, and there will be only one global Dispatcher object in Flux:

import { Dispatcher } from 'flux';

const TodoDispatcher = new Dispatcher();
Copy the code

Next, register a Store in response to the Action method:

import { ReduceStore } from 'flux/utils';

class TodoStore extends ReduceStore {
  constructor() {
    super(TodoDispatcher);
  }

  getInitialState() {
    return [];
  }

  reduce(state, action) {
    switch (action.type) {
      case 'ADD_TODO':
        return state.concat(action.payload);

      default:
        return state;
    }
  }
}
const TodoStore = new TodoStore();
Copy the code

By passing TodoDispatcher to the parent constructor call in the Store constructor, the Store is registered with the Register method on the Dispatcher as a callback to dispatch for each Action object.

At this point, you’re almost done with the Flux example; you’re left with the connect view. When the Store changes, a Change event is triggered to inform the view layer to update it. Here is the complete code:

const { Dispatcher } = require('flux');
const { ReduceStore } = require('flux/utils');

// Dispatcher
const TodoDispatcher = new Dispatcher();

// Action Types
const ADD_TODO = 'ADD_TODO';

// Action Creator
function addTodo(desc) {
  const action = {
    type: 'ADD_TODO'.payload: {
      id: Date.now(),
      done: false.desciption: desc
    }
  };
  TodoDispatcher.dispatch(action);
}

// Store
class TodoStore extends ReduceStore {
  constructor() {
    super(TodoDispatcher);
  }

  getInitialState() {
    return [];
  }

  reduce(state, action) {
    switch (action.type) {
      case ADD_TODO:
        return state.concat(action.payload);

      default:
        returnstate; }}}const todoStore = new TodoStore();

console.log(todoStore.getState()); / / []
addTodo('Get up in the morning and embrace the sun');
console.log(todoStore.getState()); // [{id: 1553392929453, done: false, desciption: 'Wake up, embrace the sun'}]
Copy the code

The Flux and the React

Architectural designs like Flux have been around for a long time, but why have they become so popular in recent years? I think a large part of this is due to the emergence of React framework. It is the Virtual DOM of React that makes data-driven mainstream and the efficient React DIff that makes such architecture more reasonable:

At the top level, near the View, there is a special View layer, which we call a View Controller, that fetches data from the Store and passes it to the View layer and its descendants, and listens for data change events in the Store.

When an event is received, the View controller first fetches the latest data from the Store and calls its own setState or forceUpdate functions, which trigger the View’s render and any descendant re-render methods.

Usually, we will pass the whole Store object to the top layer of the View chain, and then the parent node of the View will pass the Store data required by the offspring in turn. In this way, the offspring’s components will be more functional, and the number of Controller-Views will be reduced, which also means better performance.

Redux

Redux is a predictable state management container for JavaScript applications with the following features:

  • Predictability. Using Redux helps you write programs that behave consistently and are easy to test in different environments.
  • Centralized application state management can easily implement undo, restore, state persistence, and so on.
  • Adjustable, Redux Devtools offers powerful status tracking capabilities that make it easy to be a time traveler.
  • Flexible, Redux works with any UI layer and has a large ecosystem.

It also has three principles:

  • Single data source. The State of the entire application is stored in the object tree of a single Store.
  • The State State is read-only. You should not modify State directly, but instead change it by triggering an Action. Action is a normal object, so it can be printed, serialized, and stored.
  • Use pure functions to modify state. To specify how the State is transformed through the Action Action, you write the reducers pure function to handle it. Reducers calculates through the current state tree and actions, returning a new state object each time.

Is different from Flux

Redux is inspired by the Flux architecture, but has a few implementation differences:

  • Redux doesn’t have a dispatcher. It relies on pure functions to replace event handlers and does not require additional entities to manage them. Flux taste is expressed as:(state, action) => stateAnd pure functions also realize this idea.
  • Redux is an immutable data set. After each Action request is triggered, Redux generates a new object to update State, rather than making changes to the current State.
  • Redux has one and only one Store object. Its Store stores the State of the entire application.

Action

In Redux, Actions are pure JavaScript objects that describe changes to the Store’s data. They are also the Store’s source of information. In short, all changes to the data come from Actions.

In the Action object, there must be a field type that describes the type of the Action. Their values are strings. I usually store all Action types in the same file for easy maintenance (small projects don’t need to do this) :

// store/mutation-types.js
export const ADD_TODO = 'ADD_TODO'
export const REMOVE_TODO = 'REMOVE_TODO'

// store/actions.js
import * as types from './mutation-types.js'

export function addItem(item) {
  return {
    type: types.ADD_TODO,
    // .. pass item
  }
}
Copy the code

In theory, all the data structures of Action objects except type can be customized by themselves. In this paper, we recommend flux-standard-action, which specifies the basic structure information of Action objects.

{
  type: 'ADD_TODO',
  payload: {
    text: 'Do something.'}}Copy the code

There are also actions for errors:

{
  type: 'ADD_TODO',
  payload: new Error(),
  error: true
}
Copy the code

When constructing an Action, we need to make the Action object carry as little data information as possible, for example by passing an ID instead of the entire object.

Action Creator

We distinguish Action Creator from Action to avoid conflation. In Redux, Action Creator is a function that creates actions and returns an Action object:

function addTodo(text) {
  return {
    type: 'ADD_TODO',
    payload: {
      text,
    }
  }
}
Copy the code

Different from Flux, In Flux Action Creator is also responsible for triggering the dispatch operation, while Redux is only responsible for creating the Action. The actual dispatch operation is performed by the store.dispatch method: Store.dispatch (addTodo(‘something’)), which makes Action Creator’s behavior simpler and easier to test.

bindActionCreators

Instead of distributing the Action directly using the store.dispatch method, we use the connect method to get the Dispatch dispatch and use bindActionCreators to automatically bind the ActionCreators into the Dispatch function:

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

function mapDispatchToProps(dispatch) {
  return bindActionCreators(
    { addTodo },
    dispatch
  );
}

const Todo = ({ addTodo }) => {}
export default connect(null, mapDispatchToProps)(Todo);
Copy the code

With bindActionCreators, we can pass these ActionCreators to the child components, which do not need to fetch the Dispatch method but invoke it directly to trigger the Action.

Reducers

For actions, they just describe what happened, and the changes in application state are all operational changes made by Reducers.

Before realizing Reducer function, you first need to define the application of the data structure of the State, it is stored as a single object, so in the design of it, as far as possible from the global thinking to consider, and it is logically divided into different modules, use to minimize and avoid nested, and store the data and UI State respectively.

Reducer is a pure function that combines the previous state with the Action object to generate a new application state tree:

(previousState, action) => newState
Copy the code

Internal generally via switch… Case statement to handle different actions.

It is important to maintain the pure function features of Reducer. Reducer needs to do the following:

  • Instead of directly changing the original State, a new State should be generated based on the original State.
  • There should be no side effects, such as API calls, route jumps, etc.
  • When passing the same argument, the result should be consistent from call to call, so avoid it as wellDate.now()orMath.random()An impure function like this.
combineReducers

The most common State shape for Redux applications is a plain Javascript object that contains a “slice” of domain-specific data for each top-level key, and each “slice” has a reducer function of the same structure that handles data updates for that domain, Multiple reducers can also respond to the same action at the same time, independently updating their state as needed.

Because this pattern is so common, Redux provides a tool method to implement such behavior: combineReducers. It is just the most common example of how to simplify writing Redux reducers and circumvent some common problems. It also has the feature that when an Action is generated, it performs a reducer on each slice, giving the slice an opportunity to update its state. A traditional single Reducer cannot do this, so it is possible to execute this function only once under the root Reducer.

The Reducer function will be the first parameter of the createStore, and the state parameter is undefined when the Reducer is called for the first time, so we also need a way to initialize state. Here’s an example:

const initialState = { count: 0 }

functino reducer(state = initialState, action) {
  switch (action.type) {
    case: 'INCREMENT':
      return { count: state.count + 1 }
    case: 'DECREMENT':
      return { count: state.count - 1 }
    default:
      returnstate; }}Copy the code

For normal applications, State stores a wide variety of states, which can quickly make a single Reducer function difficult to maintain:

.case: 'LOADING':...case: 'UI_DISPLAY':... .Copy the code

So our core goal is to split functions as short as possible and satisfy the single responsibility principle, which is easy to maintain and extend. Let’s look at a simple TODO example:

const initialState = {
  visibilityFilter: 'SHOW_ALL',
  todos: []
}

function appReducer(state = initialState, action) {
  switch (action.type) {
    case 'SET_VISIBILITY_FILTER': {
      return Object.assign({}, state, {
        visibilityFilter: action.filter
      })
    }
    case 'ADD_TODO': {
      return Object.assign({}, state, {
        todos: state.todos.concat({
          id: action.id,
          text: action.text,
          completed: false
        })
      })
    }
    default:
      return state
  }
}
Copy the code

This function contains two independent logics: filter field setting and TODO object operation logic. If we continue to expand, the Reducer function will become larger and larger, so we need to separate these two logics for separate maintenance:

function appReducer(state = initialState, action) {
  return {
    todos: todosReducer(state.todos, action),
    visibilityFilter: visibilityReducer(state.visibilityFilter, action)
  }
}

function todosReducer(todosState = [], action) {
  switch (action.type) {
    case 'ADD_TODO': {
      return Object.assign({}, state, {
        todos: state.todos.concat({
          id: action.id,
          text: action.text,
          completed: false
        })
      })
    }
    default:
      return todosState
  }
}

function visibilityReducer(visibilityState = 'SHOW_ALL', action) {
  switch (action.type) {
    case 'SET_VISIBILITY_FILTER':
      return setVisibilityFilter(visibilityState, action)
    default:
      return visibilityState
  }
}
Copy the code

We divided the whole Reducer object into two parts, and they independently maintained the state of their own part. This design mode made the whole Reducer be divided into independent slices. Redux has a built-in combineReducers tool function that encourages us to reduce the top Reducer like this, which organizes all slices into a new Reducer function:

const rootReducer = combineReducers({
  todos: todosReducer,
  visibilityFilter: visibilityReducer
})
Copy the code

In the state object returned by combineReducers, each key name represents the key name of the Reducer that was passed in as the namespace of the state in the Reducer.

Store

There is only one single store in the Redux app, created through createStore. The Store object is used to combine Actions with Reducers and it has the following responsibilities:

  • Store the application’s State and allow it to passgetState()Method accesses State.
  • providedispatch(action)Method to update State by dispatching Action to the Reducer function.
  • throughsubscribe(listener)Listen for state changes.

Subscribe is triggered every time the Dispatch method is called, and some part of the state tree may change. We can use getState or Dispatch in the subscribe method callback, but we need to be careful. Subscribe also returns a function called unsubscribe to unsubscribe.

Redux Middleware

I believe you have a certain understanding of the concept of middleware from other applications. For Redux, when we talk about middleware, we usually refer to what we do from an Action to the Reducer. Redux provides the ability to extend three-party applications through the Middleware mechanism.

To better illustrate middleware, I’ll initialize a minimalist example with Redux:

const { createStore } = require('redux');

const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

function reducer(state = 0, action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1;
    case DECREMENT:
      throw new Error('decrement error'); 
    default:
      return state;
  }
}

void function main() {
  const store = createStore(reducer);
  store.dispatch({ type: INCREMENT }); console.log(store.getState()); // Print 1}()Copy the code

Step 1. Manually add the middleware that prints logs

In order to have a deep understanding of Redux middleware, we go step by step to implement functions with middleware capabilities. To track State changes in the program, perhaps we need to implement a log-printing middleware mechanism that prints Action and State changes after execution. We first create a Logger object from the Store object to print the log before and after dispatch:

void (function main() {
  const store = createStore(reducer);
  const logger = loggerMiddleware(store);
  logger({ type: INCREMENT });

  function loggerMiddleware(store) {
    return action => {
      console.log('dispatching', action);
      let result = store.dispatch(action);
      console.log('next state', store.getState());
      returnresult; }; }}) (); // Program running result dispatching {type: 'INCREMENT' }
next state 1
Copy the code

Step 2. Add another error-printing middleware

To monitor the state of the application, we also need to implement middleware that catches and reports errors when they occur during application Dispatch (usually to Sentry, but simply to print errors here) :

void (function main() {
  const store = createStore(reducer);
  const crasher = crashMiddleware(store);
  crasher({ type: DECREMENT });

  function crashMiddleware(store) {
    return action => {
      try {
        return dispatch(action);
      } catch (err) {
        console.error('Caught an exception! ', err); }}; }}) ();Copy the code

After you execute the program, you can see that the function correctly captures errors in DECREMENT on the command line:

Caught an exception! ReferenceError: dispatch is not defined
Copy the code

Step 3. Connect the two middleware in series

There are often multiple middleware components in an application, and connecting different middleware components together is a critical step. If you’ve read the Koa2 source code, you probably know that a function called compose handles the middleware cascade.

Here, in order to understand how it works, we will analyze it step by step. The core goal of the first two middleware is to wrap the Dispatch method in a layer. In this way, we only need to wrap the Dispatch layer by layer and pass it to the deepest middleware for invocation, which can meet the requirements of our program:

Dispatch = Store. dispatch ↓↓↓ // No middleware dispatch(Action) ↓↓ // LoggerMiddleware LoggerDispatch = action => {// LoggerMiddleware TODO Dispatch (Action) // LoggerMiddleware TODO} Dispatch (Action) ↓↓↓ // When added CrashMiddleware CrashDispatch = action => { // CrashMiddleware TODO LoggerDispatch(action) // CrashMiddleware TODO }Copy the code

If you are familiar with higher-order functions, the above idea is not too hard to understand. Let’s modify the source code to see if we can make both middleware work properly:

void function main() {
  const store = createStore(reducer);
  let dispatch = store.dispatch
  dispatch = loggerMiddleware(store)(dispatch)
  dispatch = crashMiddleware(store)(dispatch)
  dispatch({ type: INCREMENT });
  dispatch({ type: DECREMENT });

  function loggerMiddleware(store) {
    return dispatch => {
      return action => {
        console.log('dispatching', action);
        let result = dispatch(action);
        console.log('next state', store.getState());
        return result;
      };
    };
  }

  function crashMiddleware(store) {
    return dispatch => {
      return action => {
        try {
          return dispatch(action);
        } catch (err) {
          console.error('Caught an exception! ', err); }}; }; }} ();Copy the code

The print result is (as expected) :

dispatching { type: 'INCREMENT' }
next state 1
dispatching { type: 'DECREMENT' }
Caught an exception! Error: decrement error
Copy the code

Of course, we want to generate and invoke dispatches in a more elegant way, and I would expect to generate Store objects by passing in an array of middleware at creation time:

// Simple implementationfunction createStoreWithMiddleware(reducer, middlewares) {
  const store = createStore(reducer);
  let dispatch = store.dispatch;
  middlewares.forEach(middleware => {
    dispatch = middleware(store)(dispatch);
  });
  return Object.assign({}, store, { dispatch });
}


void function main() {
  const middlewares = [loggerMiddleware, crashMiddleware];
  const store = createStoreWithMiddleware(reducer, middlewares);
  store.dispatch({ type: INCREMENT });
  store.dispatch({ type: DECREMENT }); / /... } ()Copy the code

Step 4. back to Redux

Through the exploration of Steps 1 ~ 3, we have roughly implemented the middleware mechanism of Redux in accordance with the gourd. Now let’s look at the middleware interface provided by Redux itself.

In the createStore method, an enhancer parameter is supported, meaning a three-way extension, which is currently supported only for middleware created through the applyMiddleware method.

ApplyMiddleware supports passing in multiple middleware pieces that conform to the Redux middleware API, each of which has the form :({dispatch, getState}) => next => action. Let’s change it a little bit, via the applyMiddleware interface to createStore (just change the store creation step) :

/ /... const middlewares = [loggerMiddleware, crashMiddleware]; const store = createStore(reducer, applyMiddleware(... middlewares)); / /...Copy the code

Using the applyMiddleware method, you can use multiple middleware groups to form a chain. Each middleware doesn’t need to know anything about the middleware before or after it in the chain. The most common scenario for Middleware is to implement asynchronous actions methods such as Redux-thunk and redux-saga.

Asynchronous Action

For a standard Redux application, we can simply dispatch actions to perform synchronous updates. To achieve asynchronous dispatch capability, the official standard is to use the Redux-Thunk middleware.

To understand what redux-Thunk is, think back to the Middleware API above: ({dispatch, getState}) => Next => Action, thanks to a flexible middleware mechanism that provides redux-Thunk with the ability to delay action distribution, allowing people to write action Creator, Instead of returning an Action object immediately, we can return a function for asynchronous scheduling, so we call it Async Action Creator:

// synchronous, Action Creator
function increment() {
	return {
    type: 'INCREMENT'}}// asynchronous, Async Action Creator
function incrementAsync() {
  return dispatch= > {
    setTimeout((a)= > dispatch({ type: 'INCREMENT' }), 1000)}}Copy the code

The redux-thunk source code is only about 10 lines:


function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;
Copy the code

When called through Dispatch (ActionCreator()), the function determines the type of the argument:

  1. If it is an object, go through the normal triggering process and send the Action directly.
  2. If it is a function, it will be regarded as Async Action Creator, and the Dispatch method and getState method will be injected as parameters. If withExtraArgument is registered globally, it will also be passed in as the third parameter.

As for why it’s called “thunk, “it comes from “think,” where I becomes U, which means transferring absolute power from me to you, which I think is a better explanation. If you trace back to the source, this is a pattern of “evaluation strategy”, i.e. when a function argument should be evaluated, such as a function:

function test(y) { return y + 1 }
const x = 1;
test(x + 1);
Copy the code

There were two arguments:

  • Call by value, that is, evaluates before entering the function bodyx + 1 = 2, and pass the value to the function;
  • Pass calls, that is, directly to the expressionx + 1Pass in the function and evaluate the expression as needed.

This function is called Thunk. The function is called Thunk, and the function is called Thunk.

const thunk = () => (x + 1)
function test(thunk) {
  return thunk() + 1;
}
Copy the code

The resources

  • Getting Started with Redux · Redux

  • Flux | Application Architecture for Building User Interfaces

  • GitHub – redux-utilities/flux-standard-action: A human-friendly standard for Flux action objects.