React state management tools can be divided into redux and MOBx. Mobx uses the official MOBX library, but there are numerous redux frameworks derived from Redux. Such as Redux-Saga, DVA, Mirror, Rematch and so on, so many frameworks of Redux on the one hand show that Redux is so popular, on the other hand also show that Redux itself is congenital deficiency. The author himself has come all the way from the original slash-and-burn era.

The original Redux

// action_constant.js // action_creator. Js // action.js // reducer.js // store.js // add a bunch of middlewareCopy the code

Every time you change a little bit of business, you often need to change four or five files, which is really tiring. Besides, different businesses organize redux files differently. Some are organized according to components, some are organized according to functions, and every time you see a new business, you have to be familiar with it for a long time. Support for asynchrony is basically redux-thunk, redux-Promise, etc., with complex asynchrony processing and very obscure code.

redux duck

Later, in order to avoid having to modify a bunch of files and formulate file specifications for every modification, the community launched the Ducks – Modular – Redux specification, which puts the files of each sub-module into one file, which greatly simplifies some redundant work in daily development.

// widgets.js

// Actions
const LOAD   = 'my-app/widgets/LOAD';
const CREATE = 'my-app/widgets/CREATE';
const UPDATE = 'my-app/widgets/UPDATE';
const REMOVE = 'my-app/widgets/REMOVE';

// Reducer
export default function reducer(state = {}, action = {}) {
  switch (action.type) {
    // do reducer stuff
    default: return state;
  }
}

// Action Creators
export function loadWidgets() {
  return { type: LOAD };
}

export function createWidget(widget) {
  return { type: CREATE, widget };
}

export function updateWidget(widget) {
  return { type: UPDATE, widget };
}

export function removeWidget(widget) {
  return { type: REMOVE, widget };
}

// side effects, only as applicable
// e.g. thunks, epics, etc
export function getWidget () {
  return dispatch => get('/widget').then(widget => dispatch(updateWidget(widget)))
}

Copy the code

An old project I maintained is still doing this.

rematch | dva

Duck Modular Proposal reduces the maintenance cost to a certain extent, but in essence it does not reduce the amount of code required for each development. Problems such as asynchronism have not been solved, so a lot of redux-based frameworks have been derived. The focus is on simplifying boilerplate code and complex asynchronous processes. The idea of boilerplate code simplification is basically the same. We found that most business models have the following properties

const model = createModel({ name: // Global key State: XXX, // Business state reducers: XXX, // Synchronous Action Effects: XXXX, // Asynchronous Action computed: XXX // Derived data of state}Copy the code

So most frameworks use similar definitions, with differences in syntax and names

  • dva
// dva.js export default { namespace: 'products', state: [], reducers: { 'delete'(state, { payload: id }) { return state.filter(item => item.id ! == id); }, }, effects: { *add(action, { call, put }) { yield call(delay, 1000); yield put({ type: 'minus' }); }}};Copy the code
  • rematch
export const count = {
  state: 0, // initial state
  reducers: {
    // handle state changes with pure functions
    increment(state, payload) {
      return state + payload
    }
  },
  effects: (dispatch) => ({
    // handle state changes with impure functions.
    // use async/await for async actions
    async incrementAsync(payload, rootState) {
      await new Promise(resolve => setTimeout(resolve, 1000))
      dispatch.count.increment(payload)
    }
  })
}

Copy the code

The difference between the two is mainly in asynchronous processing. Dva chooses to use generator while Rematch chooses async/await. First let’s review how asynchronous streams are handled in Redux-thunk

const fetch_data = url =>  (dispatch, getState) =>{
  dispatch({
    type: 'loading',
    payload: true
  })
  fetch(url).then((response) => {
    dispatch({
      type: 'data',
      payload: response
    })
    dispatch({
      type: 'loading',
      payload: false
    })
  }).catch((err) => {
    dispatch({
      type: 'error',
      payload: err.message
    })
    dispatch({
      type: 'loading',
      payload: false
    })
  })
}

Copy the code

A simple logic for pulling data is so complicated, not to mention how to combine multiple asynchronous actions to form more complex business logic. The biggest advantage of async/await and generator is that 1. Asynchronous processes can be organized in a seemingly synchronous manner. 2. Asynchronous processes can be easily grouped together. Which one to use is a matter of personal preference. The same logic as above is written in rematch as follows

const todo = createModel({ effects: ({todo}) => ({ async fetch_data(url) { todo.setLoading(true); try { const response = fetch(url); todo.setLoading(false); }catch(err){ todo.setLoading(false); todo.setError(err.message) } }, async serial_fetch_data_list(url_list){ const result = [] for(const url of url_list){ const resp = await todo.fetch_data(url); result.push(resp); } return result; }})})Copy the code

Thanks to async/await support, neither writing asynchronous actions themselves nor composing multiple asynchronous actions is now a problem.

Most of our new businesses are still using Rematch. Compared with the previous pure Redux development experience, it has been greatly improved, but it is still not perfect, and there are still some problems as follows.

Typescript support

In the past 102 years, Typescript has become so popular that the use of Typescript has become a trend for businesses of a relatively small scale. The benefits of Typescript will not be discussed. Almost all of our businesses are developed using Typescript. Basically the biggest problem encountered in the daily development process is library support. As the saying goes, Typescript doesn’t have too many holes (it does), and libraries don’t have too many holes, but when Typescript is used with libraries, it does. Unfortunately, Dva and Rematch are not well supported for Typescript, which has a great impact on daily business development. The author once discussed how to fix the type problem of Rematch. Zhuanlan.zhihu.com/p/78741920 wrote an article, but it is still a way to hack, dva ts support is even worse, the type of the generator safety in ts3.6 version was able to fully support (and a lot of bugs), So far, I haven’t seen a dVA example that perfectly supports TS.

Batteries Included

Redux is a standard example of Batteries Included. In order to keep its purity, it left all the dirty work of asynchronous processing to the middleware, which led to a lot of third-party asynchronous processing solutions. On the other hand, it was unwilling to do higher abstraction. This leads to a lot of boilerplate code and a lot of different ways to write it. So an Batteries Included library is important enough for daily business development to ensure coding specifications and simplify business usage. Computed State and IMmutable are very important features in daily development, but Rematch leaves both functions to plug-ins, resulting in inconvenient daily use and unsatisfactory TS support from third-party plug-ins.

Only redux status can be managed

The React state and business logic basically exist in three forms

  • Redux: Stores the state of the business domain, along with some business update logic
  • Context: mainly stores some global configuration information, which is rarely changed or unchanged, such as topic and language information
  • Local: Stores more UI-related states, such as modal box display state, loading state, etc. State in the class component and useState in the hook component

Rematch basically manages the state of Redux in the simplest way, but it can only manage redux state. It has no effect on local state management.

Manage the local state

For most simple services, the management of local state is not troublesome. It basically controls the display of some pop-ups and loading. When the class component is used to control the business logic, the processing mode is relatively simple

class App extends React.Component { state = { loading: false, data: null, err: null } async componentDidMount() { this.setState({loading: true}) try { const result = await service.fetch_data() this.setState({ loading:false }) }catch(err){ this.setState({loading: false, error: err.message}) } } render(){ if(this.state.loading){ return <div>loading.... </div> }else{ return <div>{this.sstate.data}</div> } } }Copy the code

The component here plays three roles at once

  • State of the container
state = {
    loading: false,
    data: null,
    err: null
  }

Copy the code
  • State handling
async componentDidMount() {
    this.setState({loading: true})
    try {
      const result = await service.fetch_data() 
      this.setState({
        loading:false
      })
    }catch(err){
      this.setState({loading: false, error: err.message})
    }
  }

Copy the code
  • view
render(){ if(this.state.loading){ return <div>loading.... </div> }else{ return <div>{this.sstate.data}</div> } }Copy the code

This approach has both advantages and disadvantages. The advantage lies in sufficient locality, because state, state processing and rendering are closely related. When they are put together, readers can easily understand the code, but it is difficult to reuse a component because too many functions are placed in it. So different ways of reuse have been derived

Container component and view component separation: view reuse

The first way of reuse is to separate the state && state processing from the view logic through the state container component and the view component. The container component is only responsible for the state && state processing, and the view component is only responsible for the display logic. The biggest advantage of this approach is that the reuse of the view component is extremely convenient. The UI component library is the ultimate in this. We have extracted some common view components to form a component library. Most UI components have no state, or some uncontrolled components have some internal state. This component library greatly simplifies everyday UI development. The above components can be refactored as follows

// View component class Loading extends react.ponent {render(){if(this.props. Loading){return <div> Loading.... </div>}else{return <div>{this.props. Data}</div>}}} // Container component class LoadingContainer extends React.Component {state =  { loading: false, data: null, err: null } async componentDidMount() { this.setState({loading: true}) try { const result = await service.fetch_data() this.setState({ loading:false }) }catch(err){ this.setState({loading: false, error: err.message}) } } render(){ return <Loading {... This.state} /> // Render logic to view component}} // app.js <LoadingContainer>Copy the code

HOC && renderProps && Hooks: Business reuse

Reuse of view components is easy, but reuse of container components is not so easy. HOC and renderProps are derived from the community to address the reuse of state && state operations

  • HOC
// Loading.js class Loading extends React.Component { render(){ if(this.props.loading){ return <div>loading.... </div> }else{ return <div>{this.props.data}</div> } } } export default withLoading(Loading); // app.js <Loading />Copy the code
  • renderProps
<WithLoading> {(props) => { <Loading {... props} /> }} </WithLoading>Copy the code

These two approaches have some problem For high order component, there is much need to pay attention to, such as zh-hans.reactjs.org/docs/higher… , bring a lot of mental burden, for beginners is not friendly, another problem is that a HOC support for the Typescript is not friendly, implement a TS friendly HOC components have considerable difficulty may refer to www.zhihu.com/question/27… The daily use of third-party support for higher-order component libraries also often encounter various TS issues. RenderProps eliminates the problem of HOC to some extent, but it causes the renderprops callback hell. When we need to use multiple renderProps at the same time, the following code will be written

This kind of code has a significant impact both on the reader of the code and when debugging the Element structure.

  • React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks: React_hooks
// hooks.js function useLoading(){ const [loading, setLoading] = useState(false); const [ error, setError] = useState(null); const [ data,setData] = useState(null); useEffect(() => { setLoading(true); fetch_data().then(resp => { setLoading(false); setData(resp); }).catch(err => { setLoading(false); setError(err.message) }) }) } // Loading.js function Loading(){ const [loading, error, data ] = useLoading(); if(loading){ return <div>loading.... </div> }else{ return <div>{data}</div> } }Copy the code

Hooks are extremely reusable. In fact, there are many hooks that can be used directly in the community, such as github.com/alex-cory/u… This hooks to simplify code

function Loading(){ const { error, loading, data} = useHttp(url); if(loading){ return <div>loading.... </div> }else{ return <div>{data}</div> } }Copy the code

Hooks almost perfectly solve the problem of the status of reuse, but hooks itself also brings some problems, the mental burden of hooks is not less than the HOC, zh-hans.reactjs.org/docs/hooks-… The length of FAQ is obvious. Another problem is that hook can only be used in function, which means we need to organize business code in function

Function && Class who is better suited for business logic

One of the first questions most people encounter when moving from a class component to a hook component is how to organize a method in a business logic class

import React from 'react';
class App extends React.Component {
  biz1 = () =>{
  }
  biz2= () =>{
    this.biz3()
  }
  biz3= () =>{
  }
  render(){
    return (
      <div>
        <button onClick={() => this.biz1()}>dobiz1</button>
        <button onClick={() => this.biz2()}>dobiz2</button>
      </div>
    )
  }
}

Copy the code

In function, however, we no longer have the abstraction of method to help us with business isolation, so we might as well write code like this

function App (){
  const [state1, setState] = useState();
  function biz1(){

  }
  biz1();
  const [state2, setState2] = useState();
  const biz2 = useCallback(() => {
    biz3();
  },[state1,state2])
  biz2();
  return (
      <div>
        <button onClick={() => biz1()}>dobiz1</button>
        <button onClick={() => biz2()}>dobiz2</button>
      </div>
    )
  function biz3(){

  }
}

Copy the code

Basically, you can write it any way you want, there’s an infinite number of ways you can write it, but you can write it yourself, and other people who read the code are confused, and they have to jump around and around to figure out a piece of business logic.

Of course, you can also specify some specifications for writing hooks

Function APP(){// render logic // render logic // render logic}Copy the code

According to this specification, the above code is as follows

function App (){ const [state1, setState] = useState(); const [state2, setState2] = useState(); biz0(); return ( <div> <button onClick={() => biz2()}>dobiz1</button> <button onClick={() => biz2()}>dobiz2</button> </div> ) Function biz0(){// utilty} function biz1(){// utilty} function biz2(){// utilty} function biz3(){// utilty}Copy the code

This makes organizing code much more readable, but it’s just an artificial convention, there’s no esLint guarantee, and the biz definition can’t use tools like useCallback, so there’s still a problem.

Problems with writing local state

From the discussion above, we can see that while hooks solve the problem of state reuse, there are many problems with the organization and maintenance of their code

The states are all in rematch

Rematch’s state management is more tidy, so we can consider storing the local state management page in the global Redux, but this can cause some problems

  • Some states themselves are not suitable to be placed globally. For example, when some UI states of page A are switched to page B, we expect to discard the state of page A. If the state is placed in the component of A, the state will be automatically discarded as the component of A is uninstalled
  • Global state flooding: Placing some local state globally causes global state flooding, making it difficult to discern the core business logic
  • This violates the principle of locality: business logic is placed globally, resulting in frequent switching between component and global state when reading component code

Separation of Model and View

While we can’t make the state global, we can still split the component into View and Model, as rematch did. The View is responsible for pure rendering, and the Model holds the business logic

// models.ts const model = { state:{ data: null, err: null, loading: false }, setState: action((state,new_state) => { Object.assign(state,new_state) }), fetch_data: effects(async (actions) => { const { setState } = actions; setState({loading: true}); try { const resp = await fetch(); setState({ loading: false, data:resp }) }catch(err){ setState({ loading: false, err: err.mssage }) } }) } // hooks.ts import model from './model'; export const useLoading = createLocalStore(model); // loading/ index.ts import {useLoading} from './hooks'; export default () => { const [state, actions] = useLoading(); return (<Loading {... state} {... actions} />) } const Loading = ({ err, data, loading, fetch_data }) => { if(loading) return (<div>loading... </div) if(err) return (<div>error:{err}</div>) return <div onClick={fetch_data}>data:{data}</div> }Copy the code

Model: generate useLoding hooks from model, which control where to retrieve the state view: Render using state and action returned according to useLoading hooks

This way our code is organized more clearly and is less likely to have the messy situation that occurred in previous hooks

The model is important, not local or global

The only difference is whether the state exists globally or locally. If our global and local model definitions are exactly the same, In fact, it is also common in business, especially in SPA. At the beginning, the state of a page is local, but later a new page is added and needs to share the state with the new page, so we need to share the state with the new page. You can either raise the state to the common parent of both pages (via Context) or extract it globally. So for the component at this point, the difference is only where our state is read from. We isolate this distinction with hooks. When we need to switch state to global or context or local, we don’t need to modify the model, just the hook that we read

// hook.ts import model from './model'; const useLocalLoading = createLocalStore(model); // Read state from local const useConextLoading = createContextStore(model); // Read state from context const useGlobalLoading = createStore(model); Ts export default () => {const [state, actions] = useLocalLoading(); Return <Loading {... state} {... actions} /> }Copy the code

At this point, our components have reached a reasonable level of state reuse, UI reuse, and code organization, and mobx has actually adopted similar practices

Dependency injection

In the process of writing the model, Effects inevitably needed to call service to get data, which caused our model to rely on service directly. This usually didn’t have a problem, but when we did isomorphism or problems would occur. For example, a service on the browser is usually an HTTP request, while a service on the server may be an RPC service. And the call process needs to log and some trace information and the test side may be some mock HTTP service. As a result, if the Model relies directly on the Service, it will not be possible to build a model that can be used on both the server and the browser. It would be better to inject the Service into the Model through dependency injection. The service is actually injected when strore is created

It says these include Typescript support, Batteries Included, the support of localStore, dependency injection, rematch | dva libraries such as limited to historical reasons, is unlikely to support, Luckily github.com/ctrlplusb/e… Good support for all of the above. Examples can be found at github.com/hardfist/ha…

Easy peasy profile

disclaimer: Easy Peasy is similar to Rematch but lacks built-in support for hooks (although it does support react-Redux hooks). Easy Peasy has built-in hook support and does not rely on react-Redux. Instead, it is simply compatible with the use of React-Redux, so it can get rid of the existing problems of Rematch.

Typescript’s First Class support

For 9102 years, typescript support should be a basic requirement for a library, and Easy-Peasy does a great job of that by designing an API specifically for TS, To solve the TS support problem (internal USE of TS-BoolBelt to solve type inference problems), simply define a model using TS as follows

export interface TodosModel { todo_list: Item[]; // state filter: FILTER_TYPE; Init: Action<TodosModel, Item[]>; // synchronize action addTodo: action <TodosModel, string>; // setFilter: Action<TodosModel, FILTER_TYPE>; // toggleTodo: Action<TodosModel, number>; addTodoAsync: Thunk<TodosModel, string>; FetchTodo: Thunk<TodosModel, undefined, be wary >; Visible_todo: Computed<TodosModel, Item[]>; // computed state }Copy the code

With the structure of the model defined, we can enjoy automatic completion and type checking with the help of contextual typing when writing the model

In business, model is no longer used to read state and action through connect through HOC, but directly solved the problem of state reading through built-in hook. It avoids type compatibility issues with Connect (rematch is bad for compatibility here) and keeps it type safe

Built in computed and IMmer

Unlike Rematch, Easy-Peasy implements support for IMMUTABLE through Immer and has built-in support for computed State, simplifying the writing of our business

export const todo: TodosModel = { todo_list: [ { text: 'learn easy', id: nextTodoId++, completed: false } ], filter: 'SHOW_ALL' as FILTER_TYPE, init: action((state, init) => { state.todo_list = init; }), addTodo: Action ((state, text) => {// appear to be a mutable, immer is a mutable, Todo_list. Push ({text, id: nextTodoId++, completed: false}); }), setFilter: action((state, filter) => { state.filter = filter; }), toggleTodo: action((state, id) => { const item = state.todo_list.filter(x => x.id === id)[0]; item.completed = ! item.completed; }), addTodoAsync: thunk(async (actions, text) => { await delay(1000); actions.addTodo(text); }), fetchTodo: thunk(async function test(actions, payload, { injections }) { const { get_todo_list } = injections; const { data: { todo_list } } = await get_todo_list(); actions.init(todo_list); }), // Built-in support for computed visible_todo: computed(({ todo_list, filter }) => { return todo_list.filter(x => { if (filter === 'SHOW_ALL') { return true; } else if (filter === 'SHOW_COMPLETED') { return x.completed; } else { return ! x.completed; }}); })};Copy the code

Write local and global state in the same way

Easy Peasy’s Model definition works not only globally, but also with context and local, just by hook switching

export const ContextCounter = () => {
  const [state, actions] = useContextCounter();
  return renderCounter(state, actions);
};
export const LocalCounter = () => {
  const [state, actions] = useLocalCounter();
  return renderCounter(state, actions);
};
export const ReduxCounter = () => {
  const [state, actions] = useReduxCounter();
  return renderCounter(state, actions);
};

Copy the code

Dependency injection support

Easy Peasy also implements dependency injection through Thunk and ensures the type safety of dependency injection

  • Inject service when constructing store
// src/store/index.ts import {get_todo_list } from 'service' export interface Injections { get_todo_list: typeof get_todo_list; } // Use export const store = createStore(models, {injections: {// Inject service get_todo_list}});Copy the code
  • When you define a Model, declare the type to inject
import { Injections } from '.. /store'; Export interface TodosModel {items: string[]; addTodo: Action<TodosModel, string>; saveTodo: Thunk<TodosModel, string, Injections>; // Type injection}Copy the code
  • Use the injected Service, which is type safe