Redux source code analysis: understand how to use Redux thunk from the perspective of source code

Posted by xlordt on Mon, 27 Dec 2021 18:38:33 +0100

Redux source code analysis: understand how to use Redux thunk from the perspective of source code

preface

Although Redux is conceptually a relatively simple thing, that is, it changes the State through Action, and the user automatically monitors the State change of the State. However, in fact, when using reducer, the definition of Actions is becoming more and more complex. In addition, with the tricks of asynchronous Actions, thunk and other Actions definitions, it is often confusing to use it.

This article takes you to see how to use it from the perspective of source code. The second part takes you to re understand what we usually use

text

This paper is divided into two parts:

  • The first part mainly focuses on the analysis of the source code of redux thunk. At the same time, a little description of the relevant types and methods in redux will be added

  • Part II: how to write the actual combat code?

1. Source code analysis

1.1 (review) Redux Middleware

First, let's review how to write Redux middleware

The method signature of a middleware is as follows

export interface Middleware<
  _DispatchExt = {}, // TODO: remove unused component (breaking change)
  S = any,
  D extends Dispatch = Dispatch
> {
  (api: MiddlewareAPI<D, S>): (
    next: D
  ) => (action: D extends Dispatch<infer A> ? A : never) => any;
}

It looks a little complicated. Change to a simple version

const middleware = (store) => (next) => (action) => {};

A middleware needs three parameter bindings

  • Store is the bound state management object store
  • next is actually the original store Dispatch method: the middleware provides stronger processing capability for dispatch by replacing the dispatch method, which is roughly as follows (officially called Monkeypatching)
function middleware(store) {
  const next = store.dispatch;

  return function dispatch(action) {
    // do something
    next(action);
  };
}

In fact, it allows us to enter the middleware for processing when the action comes in, and then call the original dispatch method through next.

Officials have given a lot of middleware example , you can go up for reference

1.2 Redux thunk source code

Let's talk about middleware first. Next, let's see what redux thunk is. People who have used it vaguely know that it enables redux to support asynchronous operations, and it is implemented through middleware. In fact, its source code is really very simple. You can see it after reading it

  • Redux thunk full source code (really all)
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;

Redux thunk first defines a middleware factory function createtunkmiddleware, and the returned function also conforms to the middleware method signature mentioned above

What we see is actually two sentences

  1. If it is a function, call the function and pass in three parameters
if (typeof action === 'function') {
  return action(dispatch, getState, extraArgument);
}
  1. Otherwise, pass the action directly
return next(action);

Yes, it's that simple. In fact, when we add middleware to the store as follows

const store = createStore(rootReducer, applyMiddleware(thunk));

We can pass a function to the dispatch method, where it accepts three parameters: dispatch, getstate and extraargument

const asyncAction = (dispatch, getState, extraArgument) => {
  // do something
};

store.dispatch(asyncAction);

In other words, we can either call dispatch(action) at the end of the function or choose return to return an action, which will be passed down by the middleware itself.

1.3 definition of action and ActionCreator types

In fact, the above usage is relatively simple. It is nothing more than passing in an aciton object into a method.

At this time, let's go back and see what types of action s are

1.3. 1. Action type (redux source code)

export interface Action<T = any> {
  type: T;
}

export interface AnyAction extends Action {
  // Allows any extra properties to be defined in an action.
  [extraProps: string]: any;
}

We can see that the action defined in redux is very simple. Even if you are right to have a type attribute, you can inherit AnyAction type to extend if you want to add more attributes (we can agree to call it payload to avoid a large number of type definitions most of the time)

1.3. 2. Actioncreator type (redux source code)

The second one is also the writing method officially recommended by redux. It can write a factory method for producing action objects

export interface ActionCreator<A, P extends any[] = any[]> {
  (...args: P): A;
}

export interface ActionCreatorsMapObject<A = any, P extends any[] = any[]> {
  [key: string]: ActionCreator<A, P>;
}

We can see that the so-called ActionCreator actually accepts any parameter (... args) and returns A function of Action type (A)

In addition, we can also define an object with multiple actioncreators as key values to correspond to the following usage

import * as SomeActions from 'SomeActions.ts';

SomeActions in this case will be a collection of actioncreators

1.4 bindActionCreators type definition & source code

With ActionCreator, we may often need to use it like this

store.dispatch(someActionCreator(args));

When this someActionCreator is called many times, it should bring store Dispatch is very troublesome, so redux also provides the so-called bindActionCreators method to simplify our operations

1.4. 1. Bindactioncreator source code

Let's first look at the bindActionCreator of the basic model, that is, the scenario of binding a single ActionCreator

function bindActionCreator<A extends AnyAction = AnyAction>(
  actionCreator: ActionCreator<A>,
  dispatch: Dispatch
) {
  return function (this: any, ...args: any[]) {
    return dispatch(actionCreator.apply(this, args));
  };
}

P.S. here is the usage of this as a parameter. If you don't know, you can refer to the Typescript manual. It's quite interesting. It was stuck here for a long time

When we use it

const someAction = bindActionCreator(someActionCreator, store.dispatch);

After that, we can call it directly

// no need
// store.dispatch(someActionCreator(args))

// better
const someAction = bindActionCreator(someActionCreator, store.dispatch);
someAction(args);

Then the previously bound method will automatically call the bound dispatch function, and then pass in the action produced by someActionCreator

1.4. 2. Bindactioncreators source code

With a single base version of bindActionCreator, let's take a look at the implementation of the full version

// v1
export default function bindActionCreators<A, C extends ActionCreator<A>>(
  actionCreator: C,
  dispatch: Dispatch
): C;

// v2
export default function bindActionCreators<
  A extends ActionCreator<any>,
  B extends ActionCreator<any>
>(actionCreator: A, dispatch: Dispatch): B;

// v3
export default function bindActionCreators<
  A,
  M extends ActionCreatorsMapObject<A>
>(actionCreators: M, dispatch: Dispatch): M;

// v4
export default function bindActionCreators<
  M extends ActionCreatorsMapObject,
  N extends ActionCreatorsMapObject
>(actionCreators: M, dispatch: Dispatch): N;

// real function
export default function bindActionCreators(
  actionCreators: ActionCreator<any> | ActionCreatorsMapObject,
  dispatch: Dispatch
) {
  if (typeof actionCreators === 'function') {
    return bindActionCreator(actionCreators, dispatch);
  }

  if (typeof actionCreators !== 'object' || actionCreators === null) {
    throw new Error(
      `bindActionCreators expected an object or a function, but instead received: '${kindOf(
        actionCreators
      )}'. ` +
        `Did you write "import ActionCreators from" instead of "import * as ActionCreators from"?`
    );
  }

  const boundActionCreators: ActionCreatorsMapObject = {};
  for (const key in actionCreators) {
    const actionCreator = actionCreators[key];
    if (typeof actionCreator === 'function') {
      boundActionCreators[key] = bindActionCreator(actionCreator, dispatch);
    }
  }
  return boundActionCreators;
}

We can see that there are actually four overloaded methods defined in the source code, which can meet the definition forms of various actioncreators. In fact, we distinguish two types:

  1. If it is a function, it will be bound as actionCreator and returned
  2. If it is an object, it will be treated as a collection of actioncreators. After binding one by one, the whole object will be returned

It corresponds to the following two usages

// Single actionCreator
import { someActionCreator } from 'someActions';

const someAction = bindActionCreators(someActionCreator, store.dispatch);
// Collection of multiple actioncreators
import * as someActionCreators from 'someActions';

const someActions = bindActionCreators(someActionCreators, store.dispatch);

2. Return to actual combat

After reading the source code, let's think back and forth about how we use it in practice

2.0 environmental preparation

At the beginning, let's prepare the store definition and reducer for the experiment

2.0.1 store

Thunk middleware is directly added here because we already know that thunk will be distributed according to the incoming action:

  1. If the object is passed in: pass it down as an ordinary action
  2. If the function is passed in: user-defined detailed action, call and pass in dispatch, getState and other information
  • /src/createStore.ts
import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import { timerReducer } from './timer/reducers';

export default () => createStore(timerReducer, applyMiddleware(thunk));

2.0.2 timerReducer

Here we define a simple timer reducer

  • /src/timer/reducers.ts
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';

export interface ITimerState {
  count: number;
}

export enum ETimerActionType {
  INCREMENT = 'INCREMENT',
  RESET = 'RESET',
}

export interface ITimerAction extends Action<ETimerActionType> {}

export const increment: ActionCreator<ITimerAction> = () => ({
  type: ETimerActionType.INCREMENT,
});

export const reset: ActionCreator<ITimerAction> = () => ({
  type: ETimerActionType.RESET,
});

/**
 * Counter
 * @param state
 * @param action
 * @returns
 */
const timerReducer = (
  state: ITimerState = initTimerState,
  action: ITimerAction
) => {
  switch (action.type) {
    case ETimerActionType.INCREMENT:
      return { count: state.count + 1 };
    case ETimerActionType.RESET:
      return { count: 0 };
    default:
      return state;
  }
};

export { timerReducer };

The count attribute indicates the current count. There are two optional operations (INCREMENT, RESET, RESET)

2.0.3 bindLogStore

Finally, we define a method to automatically print the current state, otherwise it is really troublesome to write one by one

  • /src/utils.ts
export const bindLogStore =
  (store: Store<ITimerState, ITimerAction>) =>
  (tag: string = '') => {
    const state = store.getState();
    const prefix = tag ? `[${tag}] ` : '';
    console.log(`${prefix}state`, state);
  };

2.1 basic usage: store API

At the beginning, the most basic usage is to directly use the store API to operate. You should do everything yourself, generate action s and call the store yourself dispatch

  • /src/tests/test1_basic.ts
import createStore from '../createStore';
import { increment, reset } from '../timer/actions';
import { bindLogStore } from '../utils';

const store = createStore();

const logStore = bindLogStore(store);

logStore('init');

store.dispatch(increment());

logStore();

store.dispatch(increment());
store.dispatch(increment());
store.dispatch(increment());

logStore();

store.dispatch(reset());

logStore();

Output:

>>>>> test1_basic.ts <<<<<
  [init] state { count: 0 }
  state { count: 1 }
  state { count: 4 }
  state { count: 0 }

It's normal. Let's go on

2.2 advanced usage: cooperate with bindActionCreators

  • /src/tests/test2_bind.ts

Next, we can use bindActionCreators to simplify

import { bindActionCreators } from 'redux';
import createStore from '../createStore';
import * as timerActions from '../timer/actions';
import { bindLogStore } from '../utils';

const store = createStore();

const logStore = bindLogStore(store);

logStore('init');

const { increment, reset } = bindActionCreators(timerActions, store.dispatch);

increment();

logStore();

increment();
increment();
increment();

logStore();

reset();

logStore();

After use, the code looks much simpler, and the output is as follows:

>>>>> test2_bind.ts <<<<<
  [init] state { count: 0 }
  state { count: 1 }
  state { count: 4 }
  state { count: 0 }

Like the first one, it indicates that the running logic is correct

2.3 asynchronous Action: use Redux thunk

Finally, the protagonist of this article, asynchronous aciton

Redux thunk middleware has been added before, so we can now pass in a custom function to dispatch

So first we define two new asynchronous methods

  • /src/timer/actions.ts
export const incrementAsync: ActionCreator<ITimerAsyncAction> =
  (delay: number) => (dispatch, getState, args) =>
    new Promise((resolve, reject) => {
      setTimeout(() => {
        dispatch(increment());
        resolve(getState());
      }, delay);
    });

export const resetAsync: ActionCreator<ITimerAsyncAction> =
  (delay: number) => (dispatch, getState, args) =>
    new Promise((resolve, reject) => {
      setTimeout(() => {
        dispatch(reset());
        resolve(getState());
      }, delay);
    });

Note the structure here. In fact, these two new methods are still a kind of actionCreator. The difference is that the new "action" created is a method with the following function signature

type AsyncAction = (dispatch, getState, extraArguments) => {};

Remember that the above Redux thunks source code tells us that these three parameters are passed in

  • /src/tests/test3_async.ts
import { bindActionCreators } from 'redux';
import createStore from '../createStore';
import * as timerActions from '../timer/actions';
import { bindLogStore } from '../utils';

const store = createStore();

const logStore = bindLogStore(store);

logStore('init');

const { incrementAsync, resetAsync } = bindActionCreators(
  timerActions,
  store.dispatch
);

async function task() {
  const DELAY = 1000;
  await incrementAsync(DELAY);

  logStore();

  await incrementAsync(DELAY / 3);
  await incrementAsync(DELAY / 3);
  await incrementAsync(DELAY / 3);

  logStore();

  await resetAsync(DELAY);

  logStore();
}

task();

The two asynchronous methods we defined earlier return the promise object, so we can synchronize the final use case through the external async/await

Output:

>>>>> test3_async.ts <<<<<
  [init] state { count: 0 }

state { count: 1 }
state { count: 4 }
state { count: 0 }

epilogue

That's the end of this article. I was asked by the interviewer whether I have seen the redux thunk source code before. Recently, I finally made time to have a look. It's really short, but it's very helpful for the operation mechanism of the whole redux for your reference.

Other resources

Reference connection

TitleLink
Middleware - Redux officialhttps://redux.js.org/understanding/history-and-design/middleware
redux - Githubhttps://github.com/reduxjs/redux
redux-thunk - Githubhttps://github.com/reduxjs/redux-thunk
declaring-this-in-a-function - TypeScripthttps://www.typescriptlang.org/docs/handbook/2/functions.html#declaring-this-in-a-function
What is the syntax for Typescript arrow functions with generics?https://qastack.cn/programming/32308370/what-is-the-syntax-for-typescript-arrow-functions-with-generics

Complete code example

https://github.com/superfreeeee/Blog-code/tree/main/front_end/others/redux_thunk_source

Topics: React redux asynctask