This article, the third in a series on Managing Application State with RxJS + Redux, covers our confusion with Redux, how we can rethink the paradigm set by Redux, and what we can do about it. Back to Article 1: Component autonomy using Redux-Observable

Summary of article addresses in this series:

  • Use redux-Observable to implement component autonomy
  • How to implement a Redux-Observable
  • Better Redux

Why do we need Redux?

First of all, Redux is not a React plugin. It is a state management model that follows the trend of component-based front-end development. You can use it in Vue or Angular.

Currently, it is generally accepted that the state of an application or component at a given time corresponds to the UI of the application or component at that time:

UI = f(state)
Copy the code

Then, when developing front-end componentization, you need to think about two questions:

  1. State the source
  2. State management

The state that a component has comes from two sources:

  1. Self state: For example, a Button component has a count state of its own that indicates how many times it has been clicked.
  2. Externally injected state: For example, a Modal component needs to be externally injected to show whether the state is visible. React calls externally injected states props.

The state source delivers the required state to the component, which in turn confirms the appearance of the component. In simple projects and simple components, we think about the source of state, and if we introduce additional state management schemes (for example, we manage the state of a button component using Redux), we can impose a burden on each component, creating unnecessary abstractions and dependencies.

For large front-end engineering and complex components, it usually has the following characteristics:

  1. Complex data
  2. Rich component

In this scenario, the naive state management appears to be inadequate, mainly reflected in the following aspects:

  1. When the component hierarchy is too deep, how can it elegantly present the state that the component needs, or how can it more easily obtain the state that it needs
  2. How do you go back to a state
  3. How to better manage test state

Redux addresses these issues to make the state of large front-end projects more manageable. Redux proposes a convention model that centralizes state updates and distribution:

The model used by Redux was inspired by Elm:

In Elm, the flow of the application is the message (MSG) : a data structure identified by the message type and carrying the payload **. Messages determine how the data model is updated, which in turn determines the UI shape.

In Redux, messages are called actions, and reducer is used to describe state transitions. Also, unlike Elm, Redux focuses on state management and no longer deals with views, so Redux is not typed (see the blog post for an introduction to the typing architecture).

After realizing the benefits of Redux, or being attracted by its popularity, we introduced Redux as the state manager of the application, which makes the state changes of the entire application extremely clear. States flow in a link, and we can even go back or forward to a certain state. But is Redux really perfect?

Imperfect Redux

Redux is certainly not perfect, and it bothers us the most in two ways:

  1. Verbose boilerplate code
  2. Poor asynchronous task processing ability

Assume that the front end needs to pull some data from the server and display it. In Redux mode, to complete the pull from the data to the status update, you need to go through:

(1) Define several action types:

const FETCH_START = 'FETCH_START'
const FETCH_SUCCESS = 'FETCH_SUCCESSE'
const FETCH_ERROR = 'FETCH_ERROR'
Copy the code

(2) Define several Action Creators, assuming we use redux-thunk to drive asynchronous tasks:

const fetchSuccess = data= > ({
  type: FETCH_START,
  payload: { data }
})

const fetchError = error= > ({
  type: FETCH_ERROR,
  payload: { error }
})

const fetchData = (params) = > {
  return (dispatch, getState) = > {
    return api.fetch(params)
      .then(fetchSuccess)
    	.catch(fetchError)
  }
}
Copy the code

(3) In reducer, different status update methods for different action types were declared through switch-case:

function reducer(state = initialState, action) {
  const { type, payload } = action
  switch(action.type){
    case FETCH_START: {
      return { ...state, loading: true}}case FETCH_SUCCESS: {
      return { ...state, loading: false.data: payload.data }
    }
    case FETCH_ERROR: {
      return { ...state, loading: false.data: null.error: payload.error}
    }
  }
}
Copy the code

The problem with this process is:

  1. Lack of concentration in personal development: In the project, we managed action type, action and reducer separately. After completing a set of procedures, we needed to keep jumping in the process and thought was not focused enough.
  2. Multi-member collaboration is not efficient enough: due to the dispersal of action type, action and reducer, there will be name conflicts and duplicate processes of similar businesses when multi-member collaboration occurs. This puts forward a relatively high request to our application state design. A good design is that the state is easy to locate, the transition process is clear, and there is no redundant state, while a poor design will make the state expansion difficult to locate, the transition process is complex, and redundant state can be seen everywhere.

How to use Redux well

Switching to other state management solutions (such as MOBx or Mox-state-stree) is not practical when we are stuck with the negative impact of Redux, both because of the high migration costs and because you don’t know if the new state management solution will be the silver bullet. But doing nothing or ignoring Redux’s negative effects will only allow the problem to spiral out of control.

Before we start talking about how to make Redux better, it’s important to make it clear that the lack of boilerplate code and asynchronous capabilities is a result of Redux’s design, not its purpose. In other words, Redux is not designed to leave developers writing sample code or struggling with how to handle asynchronous status updates.

We need to define another character to write boilerplate code for us, give us the best asynchronous task handling, and put him in charge of all the disgusting things in Redux. Thus, this character is a framework for making Redux more elegant. To create this character, we need to start with individual components, rethink the application form, and look at:

  1. How to get rid of Redux boilerplate code
  2. How to handle asynchronous tasks more gracefully

What components look like

The ecology of a component might look something like this:

That is, the data is processed to form the page state, and the page state determines UI rendering.

What the application looks like

The combination of component ecology (UI + state + state management) makes up our application:

The component ecology purposely only shows the data-to-state step here, because that’s what Redux deals with. For the moment, we can define the process from data to state as flow, meaning a business flow.

partitioning

Referring to Elm, we can divide applications according to the data model:

Among them, the model has the following attributes:

  • name: Model name
  • state: Initial state of the model
  • reducers: Handles the state of the current model state
  • selectors: State selectors that serve the current model
  • flows: Business flows involved in the current model (side effects)

This classic division model is just the application division means of Dva, but the model attributes are slightly different.

Assuming we create the User and POST models, the framework will mount their state to the user and POST state subtrees:

Convention – Remove boilerplate code

With the concept of a model, the framework can define a set of conventions to reduce boilerplate writing. First, let’s review how we defined an action type in the past:

  • The name of the action
  • Specifying a namespace prevents name conflicts

For example, we define the user data pull related action type as follows:

const FETCH = 'USRE/FETCH'
const FETCH_SUCCESS = 'USER/FETCH_SUCCESSE'
const FETCH_ERROR = 'USER/FETCH_ERROR'
Copy the code

FETCH corresponds to an action for asynchronously fetching data, while FETCH_SUCCESS and FETCH_ERROR correspond to two actions for synchronously modifying the state.

Synchronizing action conventions

For synchronous actions that do not contain side effects, we directly present them to the reducer, which will not destroy the purity of the reducer. Therefore, we can agree that the reducer name under model maps an action type that directly operates on the state:

SYNC_ACTION_TYPE = MODEL_NAME/REDUCER_NAME
Copy the code

For example, the following user model:

const userModel = {
  name: 'user'.state: {
    list: [].total: 0.loading: false
  },
  reducers: {
    fetchStart(state, payload) {
      return { ...state, loading:true}}}}Copy the code

FetchStart reducer When we send an action of type user/fetchStart, the action enters the reducer with its payload and changes its state.

Asynchronous Action convention

For asynchronous actions, we cannot directly handle asynchronous tasks in reducer, and the flow in Model is the container of asynchronous tasks:

ASYNC_ACTION_TYPE = MODEL_NAME/FLOW_NAME
Copy the code

For example, the following model:

const user = {
  name: 'user'.state: {
    list: [].total: 0.loading: false
  },
  flows: {
    fetch() {
      / /... Handle some asynchronous tasks}}}Copy the code

If we issue a user/fetch in the UI, since there is a flow named FETCH in the User model, we will enter the flow for asynchronous task processing.

State coverage and update

It would be too much to write a reducer for each state update, so we can consider defining a change Reducer for each model to directly update the state:

const userModel = {
  name: 'user'.state: {
    list: [].pagination: {
      page: 1.total: 0
    },
    loading: false
  },
  reducers: {
    change(state, action) {
      return{... state, ... action.payload } } } }Copy the code

At this point, we can set the loading state to true when we send out the following action:

dispatch({
  type: 'user/change'.payload: {
    loading: true}})Copy the code

However, this update is overridden, assuming we want to update the current page information in the status:

dispatch({
  type: 'user/change'.payload: {
    pagination: { page: 1}}})Copy the code

The state becomes:

{
  list: [].pagination: {
  	page: 1
  },
  loading: false
}
Copy the code

The pagination state is completely overwritten, and the total state is lost.

Therefore, we need to define a patch reducer, which is a patch reducer for the state. It only affects the substates declared in the action payload:

import { merge } from 'lodash.merge'
const userModel = {
  name: 'user'.state: {
    list: [].pagination: {
      page: 1.total: 0
    },
    loading: false
  },
  reducers: {
    change(state, action) {
      return {
        { ...state, ...action.payload }
      }
    },
    patch(state, action) {
      return deepMerge(state, action.payload)
    }
  }
}
Copy the code

Now, let’s try updating only the pages:

dispatch({
  type: 'user/patch'.payload: {
    pagination: { page: 1}}})Copy the code

The new state is:

{
  list: [].pagination: {
  	page: 1.total: 0
  },
  loading: false
}
Copy the code

Note: This is not a production implementation. It is not enough to use lodash’s merge directly.

Organization of asynchronous tasks

Dva uses Redux-Saga to organize side effects (mainly asynchronous tasks) and Rematch uses async/await to organize. In terms of long-term practice, I prefer to use Redux-Observable, especially after the release of version 1.0, which brings observable state$and enables us to practice responsive programming more thoroughly. Let’s review the benefits of this pattern mentioned above:

  • Unify data sources and combine observables
  • Declarative programming, straightforward and concise code
  • Excellent race handling skills
  • Test the friendly
  • This facilitates component autonomy

Therefore, we choose Redux-Observable to handle model asynchronous tasks:

const user:Model<UserState> = {
  name: 'user',
  state: {
    list: [],
    // ...
  },
  reducers: {
    // ...
  },
  flows: {
    fetch(flow$, action$, state$) {
      / /...}}}Copy the code

In a slightly different way from Epic’s function signature, each flow has an additional flow$parameter, which in the above example is equivalent to:

action$.ofType('user/fetch')
Copy the code

This parameter allows us to get the action we need faster.

Handle load state and error state

Front-end engineering often has the need for error displays and load displays,

If we manually manage the load state and error state of each model, it would be too troublesome. Therefore, in the root state, two state subtrees are separately divided to deal with the load state and error state. In this way, it is convenient for the framework to govern the load state and error state, and developers can directly access the state tree:

  • loading
  • error

As shown in the figure, loading state and error state also need to be divided according to granularity. There is a flow level with large granularity, which is used to identify whether a flow is in progress. There are also small-grained service levels that identify whether an asynchronous service is in progress.

For example, if:

loading.flows['user/fetch'= = =true
Copy the code

That is, fetch flow under user Model is in progress.

If:

loading.services['/api/fetchUser'= = =true
Copy the code

The/API /fetchUser service is in progress.

Responsive service governance

There is a broad need for the front-end to call the back-end services to manipulate data, so we also want so-called intermediary actors (frameworks) to inject services into our business flow and complete the interaction between services and application state: Observe the call status, automatically capture the call exceptions, and timely modify the loading state and error state, so that users can directly access the service running status from the top state.

In addition, under the paradigm of responsive programming, the service governance provided by the framework should be responsive in handling the success and error of the service, that is, the success and error will be predefined streams (Observable objects), so that developers can make better use of the capabilities of responsive programming:

const user:Model<UserState> = {
  name: 'user',
  state: {
    list: [],
    total: 0
  },
  reducers: {
    fetchSuccess(state, payload) {
      return { ...state, list: payload.list, total: payload.total }
    },
    fetchError(state, payload) {
      return { ...state, list:[] }
    }
  },
  flows: {
    fetch(flow$, action$, state$, dependencies) {
      const { service } = dependencies
      return flow$.pipe(
        withLatestFrom(state$, (action, state) = > {
          // Assemble request parameters
          return params
        }),
        switchMap(params= > {
          const [success$, error$] = service(getUsers(params))
          return merge(
            success$.pipe(
              map(resp= > ({
                type: 'user/fetchSuccess',
                payload: {
                  list: resp.list,
                  total: resp.total
                }
            	}))
            ),
            error$.pipe(
              map(error= > ({
              	type: 'user/fetchError'}))))}))}}}Copy the code

reobservable

Dva Architecture + Redux-Observable, which eliminates redux’s verbose boilerboard code, and Redux-Observable, which manages asynchronous tasks.

Unfortunately, Dva does not use Redux-Observable for side effects management, nor does it use redux-Observable or RxJS for side effects management. The Dva middleware to implement a Redux-Observable through the hook exposed by Dva is also not smooth. Therefore, the author tries to write a ReObservable to implement the framework mentioned above, which is different from Dva:

  1. Focus only on application state, not the rest of the ecology of component routing
  2. Integrate loading and error handling
  3. Use redux-Observable instead of Redux-Saga to handle side effects
  4. Responsive service processing, enabling application of custom service details

If your app uses Redux, you suffer from the negative effects of Redux, and you’re a fan of responsive programming and RxJS, try ReObservable. But if you prefer Saga, or Async await, you should still opt for Dva or Rematch.

The resources

  • Redesigning Redux
  • The Elm Architecture
  • UNIDIRECTIONAL USER INTERFACE ARCHITECTURES

About this Series

  • This series will start with the introduction of Redux-Observable 1.0 and explain my experience in combining RxJS with Redux. The content involved will include redux-Observable practice introduction, redux-Observable implementation principle exploration, Finally, I will introduce reObservable, a state management framework based on Redux-Observble + DVA Architecture.
  • This series is not an introduction to RxJS or Redux, but rather their basic concepts and core strengths. If you search for RxJS and stumble into this series and become interested in RxJS and FRP programming, then I would recommend getting started:
    • learnrxjs.io
    • Andre Staltz’s series of classes at Egghead. IO
    • RxJS by Cheng Mo
  • This series is not intended to be a tutorial, but rather an introduction to some of my own ideas on how to use RxJS in Redux. Hopefully, more people will point out some of the pitfalls and share more elegant practices.
  • I would like to express my sincere thanks for the help of some senior students on the way of practice, especially For the guidance of Questguo from Tencent Cloud. Reobservable is derived from TCFF, the React framework led by Tencent Cloud QuestGuo, and looks forward to the open source of TCFF in the future.
  • Thanks to Xiaoyu for the design support.