DEV Community

Cover image for Talk about Concent & Recoil in detail, explore the new development model of react data flow
幻魂
幻魂

Posted on

Talk about Concent & Recoil in detail, explore the new development model of react data flow

Open source is not easy, thank you for your support, ❤ star me if you like concent ^_^

Preface

Recently, more and more of the latest state management solutions from facebook recoil have been mentioned gradually. Although it is still in an experimental state, everyone has already started privately. I want to try it. After all, I was born famous and has fb endorsement, and I will definitely shine.

However, after I experienced recoil, I remained skeptical about the precise update advertised in it, and there were some misleading suspicions. This point will be analyzed separately below. Whether it is misleading, readers can naturally draw conclusions after reading this article , In short, this article mainly analyzes the code style differences between Concent and Recoil, and discusses their new influence on our future development model, and what kind of change in thinking needs to be done.

3 major genres of data flow solutions

The current mainstream data flow solutions can be divided into the following three categories according to the form

-redux genre
Redux, other works derived from redux, and works similar to redux ideas, representative works include dva, rematch, and so on.

-mobx genre
With the help of definePerperty and Proxy to complete data hijacking, so as to achieve the representative of the purpose of responsive programming, there are also many mobx-like works, such as dob.

-Context genre
Context here refers to the Context api that comes with React. Data flow solutions based on Context api are usually lightweight, easy to use, and less overview. Representative works are unstated, constate, etc. The core code of most works may not exceed 500 Row.

At this point, let's see which category Recoil should belong to? Obviously it belongs to the Context genre according to its characteristics, so the main light weight we said above
Recoil is not applicable anymore. Open the source code library and find that the code is not done in a few hundred lines. Therefore, it is not necessarily lightweight based on the Context api that is easy to use and powerful. It can be seen that facebook is against Recoil It is ambitious and gives high hopes.

Let's also see which category Concent belongs to? After the v2 version of Concent, the data tracking mechanism was refactored, and the defineProperty and Proxy features were enabled, so that the react application not only retains the pursuit of immutability, but also enjoys the performance improvement benefits of runtime dependency collection and precise UI update. Now that defineProperty and Proxy are enabled, it seems that Concent should belong to the mobx genre?

In fact, Concent belongs to a brand new genre. It does not rely on React's Context API, does not destroy the form of the React component itself, maintains the philosophy of pursuing immutability, and only establishes a logical layer state on top of React's own rendering scheduling mechanism. Distributed scheduling mechanism, defineProperty and Proxy are only used to assist in collecting instances and derived data dependent on module data, and modifying the data entry is still setState (or dispatch, invoke, sync based on setState encapsulation), so that Concent can be accessed with zero intrusion Into the react application, true plug-and-play and non-perceived access.

The core principle of plug and play is that Concent builds a global context parallel to the react runtime, carefully maintains the attribution relationship between the module and the instance, and at the same time takes over the update entry setState of the component instance , Keep the original setState as reactSetState. When the user calls setState, in addition to calling reactSetState to update the current instance ui, Concent also intelligently judges whether there are other instances in the submitted state that care about its changes, and then take them out and execute these instances in turn The reactSetState achieves the purpose of all the states are synchronized.

Recoil first experience

Let's take the commonly used counter as an example to get familiar with the four frequently used APIs exposed by Recoil
-atom, defines the state
-selector, define derived data
-useRecoilState, consumption state
-useRecoilValue, consumption derived data

Define status

Use the atom interface externally, define a state where the key is num and the initial value is 0

const numState = atom({
  key: "num",
  default: 0
});
Enter fullscreen mode Exit fullscreen mode

Define derived data

Use the selector interface externally, define a key as numx10, and the initial value is calculated again by relying on numState

const numx10Val = selector({
  key: "numx10",
  get: ({ get }) => {
    const num = get(numState);
    return num * 10;
  }
});
Enter fullscreen mode Exit fullscreen mode

Define asynchronous derived data

The get of selector supports defining asynchronous functions

The point to note is that if there is a dependency, the dependency must be written before the asynchronous logic is executed.

const delay = () => new Promise(r => setTimeout(r, 1000));

const asyncNumx10Val = selector({
  key: "asyncNumx10",
  get: async ({ get }) => {
    // !!! This sentence cannot be placed under delay, the selector needs to be synchronized to determine the dependency
    const num = get(numState);
    await delay();
    return num * 10;
  }
});
Enter fullscreen mode Exit fullscreen mode

Consumption status

Use the useRecoilState interface in the component to pass in the state you want to get (created by atom)

const NumView = () => {
  const [num, setNum] = useRecoilState(numState);

  const add = ()=>setNum(num+1);

  return (
    <div>
      {num}<br/>
      <button onClick={add}>add</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Consumption derived data

Use the useRecoilValue interface in the component to pass in the derived data you want to get (created by selector). Both synchronous and asynchronous derived data can be obtained through this interface

const NumValView = () => {
  const numx10 = useRecoilValue(numx10Val);
  const asyncNumx10 = useRecoilValue(asyncNumx10Val);

  return (
    <div>
      numx10 :{numx10}<br/>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Render them to see the results

Expose these two defined components, View online example

export default ()=>{
  return (
    <>
      <NumView />
      <NumValView />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The top node wraps React.Suspense and RecoilRoot, the former is used to meet the needs of asynchronous calculation functions, the latter is used to inject the context of Recoil

const rootElement = document.getElementById("root");
ReactDOM.render(
  <React.StrictMode>
    <React.Suspense fallback={<div>Loading...</div>}>
      <RecoilRoot>
        <Demo />
      </RecoilRoot>
    </React.Suspense>
  </React.StrictMode>,
  rootElement
);
Enter fullscreen mode Exit fullscreen mode

Concent first experience

If you have read the concent document (still under construction...), some people may think that there are too many APIs and it is difficult to remember. In fact, most of them are optional syntactic sugar. Let’s take counter as an example, and only need to use The following two apis
-run, define module status (required), module calculation (optional), module observation (optional)

After running the run interface, a concent global context will be generated
-setState, modify state

Define status & modify status

In the following example, we first break away from ui and directly complete the purpose of defining state & modifying state

import {run, setState, getState} from "concent";

run({
  counter: {// Declare a counter module
    state: {num: 1 }, // define state
  }
});

console.log(getState('counter').num);// log: 1
setState('counter', {num:10});// Modify the num value of the counter module to 10
console.log(getState('counter').num);// log: 10
Enter fullscreen mode Exit fullscreen mode

We can see that this is very similar to redux, a single state tree needs to be defined, and the first-level key guides users to modularize the management of data.

Introducing reducer

In the above example, we directly drop one setState to modify the data, but the real situation is that there are many synchronous or asynchronous business logic operations before the data falls, so we fill in the reducer definition for the module to declare the modification of the data Method collection.

import {run, dispatch, getState} from "concent";

const delay = () => new Promise(r => setTimeout(r, 1000));

const state = () => (( num: 1 ));//State statement
const reducer = {// reducer declaration
  inc(payload, moduleState) {
    return {num: moduleState.num + 1 };
  },
  async asyncInc(payload, moduleState) {
    await delay();
    return {num: moduleState.num + 1 };
  }
};

run({
  counter: {state, reducer}
});
Enter fullscreen mode Exit fullscreen mode

Then we use dispatch to trigger the method of modifying the state

Because dispatch returns a Promise, we need to wrap it up with an async to execute the code

import {dispatch} from "concent";

(async ()=>{
  console.log(getState("counter").num);// log 1
  await dispatch("counter/inc");// Synchronous modification
  console.log(getState("counter").num);// log 2
  await dispatch("counter/asyncInc");// Asynchronous modification
  console.log(getState("counter").num);// log 3
})()
Enter fullscreen mode Exit fullscreen mode

Note that the dispatch call is based on the string matching method. The reason why this call method is retained is to take care of the scenes that need to be dynamically called.

import {dispatch} from "concent";

await dispatch("counter/inc");
// change into
await dispatch(reducer.inc);
Enter fullscreen mode Exit fullscreen mode

In fact, the reducer set defined by the run interface has been centrally managed by concent and allows users to call it in the way of reducer.${moduleName}.${methodName}, so here we can even base it on the reducer Make a call

import {reducer as ccReducer} from'concent';

await dispatch(reducer.inc);
// change into
await ccReducer.counter.inc();
Enter fullscreen mode Exit fullscreen mode

Connect to react

The above example mainly demonstrates how to define the state and modify the state, then we need to use the following two APIs to help the react component generate the instance context (equivalent to the rendering context mentioned in the vue 3 setup), and obtain the consumption concentration module Data capabilities

-register, the registered component is a concent component
-useConcent, register the function component as a concent component

import {register, useConcent} from "concent";

@register("counter")
class ClsComp extends React.Component {
  changeNum = () => this.setState({ num: 10 })
  render() {
    return (
      <div>
        <h1>class comp: {this.state.num}</h1>
        <button onClick={this.changeNum}>changeNum</button>
      </div>
    );
  }
}

function FnComp() {
  const {state, setState} = useConcent("counter");
  const changeNum = () => setState({ num: 20 });

  return (
    <div>
      <h1>fn comp: {state.num}</h1>
      <button onClick={changeNum}>changeNum</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note that the difference between the two writing methods is very small, except for the different definition of components, in fact, the rendering logic and data sources are exactly the same.

Render them to see the results

Online example

const rootElement = document.getElementById("root");
ReactDOM.render(
  <React.StrictMode>
    <div>
      <ClsComp />
      <FnComp />
    </div>
  </React.StrictMode>,
  rootElement
);
Enter fullscreen mode Exit fullscreen mode

Comparing with Recoil, we found that there is no top layer and no Provider or Root similar component package, the react component has been connected to the concentration, which achieves true plug-and-play and non-perceptual access, while the api remains It is consistent with react.

Component calls reducer

Concent generates an instance context for each component instance, which is convenient for users to directly call the reducer method through ctx.mr

mr is shorthand for moduleReducer, and it is legal to write it directly as ctx.moduleReducer

// --------- For class components -----------
changeNum = () => this.setState({ num: 10 })
// ===> amended to
changeNum = () => this.ctx.mr.inc(10);// or this.ctx.mr.asynInc(10)

//Of course, this can also be written as ctx.dispatch call, but it is more recommended to use the above moduleReducer to call directly
//this.ctx.dispatch('inc', 10); // or this.ctx.dispatch('asynInc', 10)

// --------- For function components -----------
const {state, mr} = useConcent("counter");// useConcent returns ctx
const changeNum = () => mr.inc(20); // or ctx.mr.asynInc(10)

//For function groups, the dispatch method will also be supported
//ctx.dispatch('inc', 10); // or ctx.dispatch('asynInc', 10)
Enter fullscreen mode Exit fullscreen mode

Asynchronous calculation function

The run interface supports the extension of the computed attribute, which allows users to define a collection of calculation functions for derived data. They can be synchronous or asynchronous, and support one function to use the output of another function as input. In the second calculation, the input dependency of the calculation is automatically collected.

 const computed = {// Define the collection of calculation functions
  numx10({ num }) {
    return num * 10;
  },
  // n:newState, o:oldState, f:fnCtx
  // Structure num, indicating that the current calculation dependency is num, and this function is triggered to recalculate only when num changes
  async numx10_2({ num }, o, f) {
    // It is necessary to call setInitialVal to give numx10_2 an initial value,
    // This function is only executed once when the first computed trigger is triggered
    f.setInitialVal(num * 55);
    await delay();
    return num * 100;
  },
  async numx10_3({ num }, o, f) {
    f.setInitialVal(num * 1);
    await delay();
    // use numx10_2 to calculate again
    const ret = num * f.cuVal.numx10_2;
    if (ret% 40000 === 0) throw new Error("-->mock error");
    return ret;
  }
}

// Configure to the counter module
run({
  counter: {state, reducer, computed}
});
Enter fullscreen mode Exit fullscreen mode

In the above calculation function, we deliberately let numx10_3 report an error at some point. For this error, we can define errorHandler in the second options configuration of the run interface to catch it.

run({/**storeConfig*/}, {
    errorHandler: (err)=>{
        alert(err.message);
    }
})
Enter fullscreen mode Exit fullscreen mode

Of course, it is better to use the concent-plugin-async-computed-status plugin to complete the unified management of the execution status of all module calculation functions.

import cuStatusPlugin from "concent-plugin-async-computed-status";

run(
  {/**storeConfig*/},
  {
    errorHandler: err => {
      console.error('errorHandler', err);
      // alert(err.message);
    },
    plugins: [cuStatusPlugin], // Configure asynchronous calculation function execution status management plugin
  }
);
Enter fullscreen mode Exit fullscreen mode

The plug-in will automatically configure a cuStatus module to concent, so that components can connect to it and consume the execution status data of related calculation functions

function Test() {
  const {moduleComputed, connectedState, setState, state, ccUniqueKey} = useConcent({
    module: "counter",// belongs to the counter module, the state is obtained directly from the state
    connect: ["cuStatus"],// Connect to the cuStatus module, the status is obtained from connectedState.{$moduleName}
  });
  const changeNum = () => setState({ num: state.num + 1 });

  // Get the execution status of the calculation function of the counter module
  const counterCuStatus = connectedState.cuStatus.counter;
  // Of course, the execution status of the specified settlement function can be obtained in a more granular manner
  // const {['counter/numx10_2']:num1Status, ['counter/numx10_3']: num2Status} = connectedState.cuStatus;

  return (
    <div>
      {state.num}
      <br />
      {counterCuStatus.done? moduleComputed.numx10:'computing'}
      {/** Errors obtained here can be used for rendering, of course also thrown out */}
      {/** Let components like ErrorBoundary capture and render the degraded page */}
      {counterCuStatus.err? counterCuStatus.err.message:''}
      <br />
      {moduleComputed.numx10_2}
      <br />
      {moduleComputed.numx10_3}
      <br />
      <button onClick={changeNum}>changeNum</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

View online example

Accurate update

At the beginning, I said that I remain skeptical about the precise update mentioned by Recoli, and there are some misleading suspicions. Here we will uncover the suspicion

Everyone knows that hook rules cannot be written in conditional control statements, which means that the following statements are not allowed

const NumView = () => {
  const [show, setShow] = useState(true);
  if(show){// error
    const [num, setNum] = useRecoilState(numState);
  }
}
Enter fullscreen mode Exit fullscreen mode

So if the user does not use this data for a certain state in the UI rendering, changing the value of num somewhere will still trigger the re-rendering of NumView, but the state and from the instance context ofconcentmoduleComputed is a Proxy object, which collects the dependencies required for each round of rendering in real time. This is true on-demand rendering and accurate update.

const NumView = () => {
  const [show, setShow] = useState(true);
  const {state} = useConcent('counter');
  // When show is true, the rendering of the current instance depends on the rendering of state.num
  return {show? <h1>{state.num}</h1>:'nothing'}
}
Enter fullscreen mode Exit fullscreen mode

Click me to view the code example

Of course, if the user needs to do other things when the num value has been rendered after the ui is changed, similar to the effect of useEffect, concent also supports users to extract it into setup and define effect to complete this Scenario requirements, compared to useEffect, the ctx.effect in the setup only needs to be defined once, and only the key name is passed. Concent will automatically compare the value of the previous moment and the current moment to determine whether to trigger the side effect function.

conset setup = (ctx)=>{
  ctx.effect(()=>{
    console.log('do something when num changed');
    return ()=>console.log('clear up');
  }, ['num'])
}

function Test1(){
  useConcent({module:'cunter', setup});
  return <h1>for setup<h1/>
}
Enter fullscreen mode Exit fullscreen mode

More about effect and useEffect, please check this article

current mode

Regarding the question of whether concent supports current mode, let’s talk about the answer first. concent is 100% fully supported, or further, all state management tools are ultimately triggered by setState or forceUpdate. We As long as you don't write code with any side effects during the rendering process, letting the same state input to the power of the rendering result is safe to run in current mode.

current mode just puts forward more stringent requirements on our code.

// bad
function Test(){
   track.upload('renderTrigger');// Report rendering trigger event
   return <h1>bad case</h1>
}

// good
function Test(){
   useEffect(()=>{
      // Even if setState is executed only once, the component may be rendered repeatedly in current mode,
      // But React internally guarantees that the side effect will only be triggered once
      track.upload('renderTrigger');
   })
   return <h1>bad case</h1>
}

Enter fullscreen mode Exit fullscreen mode

We first need to understand the principle of current mode because the fiber architecture simulates the entire rendering stack (that is, the information stored on the fiber node), which allows React to schedule the rendering process of the component in the unit of component. Stop and enter the rendering again, arrange the high priority to render first, and the heavily rendered components will slice repeatedly rendering for multiple time periods, and the context of the concentration itself is independent of the existence of react (the access to the concentration does not require anymore The top-level package is any Provider), which is only responsible for processing the business to generate new data, and then dispatching it to the corresponding instance on demand (the state of the instance itself is an island, and concent is only responsible for synchronizing the data of the dependent store), and then react In its own scheduling process, the function that modifies the state will not be executed multiple times due to repeated reentry of the component (this requires us to follow the principle of writing code that contains side effects during the rendering process), react is only scheduling the rendering timing of the component , And the component's interruption and reentry are also aimed at this rendering process.

So the same, for concent

const setup = (ctx)=>{
  ctx.effect(()=>{
     // effect is an encapsulation of useEffect,
     // This side effect is only triggered once in current mode (guaranteed by react)
      track.upload('renderTrigger');
  });
}

// good
function Test2(){
   useConcent({setup})
   return <h1>good case</h1>
}

Enter fullscreen mode Exit fullscreen mode

Similarly, in the current mode mode of dependency collection, repeated rendering only triggers multiple collections. As long as the state input is the same, the rendering result is idempotent, and the collected dependency results are also idempotent.

// Assuming that this is a time-consuming component to render, rendering may be interrupted in current mode
function HeavyComp(){
  const {state} = useConcent({module:'counter'});// belongs to the counter module

 // Two values ​​of num and numBig are read here, and the dependencies are collected
 // That is, only when the num and numBig of the counter module change, the re-rendering is triggered (the setState is finally called)
 // When other values ​​of the counter module change, the setState of the instance will not be triggered
  return (
    <div>num: {state.num} numBig: {state.numBig}</div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can sort out the fact that hook itself is a custom hook (function without ui return) that supports the stripping of logic, and other state management is just another layer of work to guide users to strip the logic to them Under the rules, the business processing data is finally returned to the react component to call its setState or forceUpdate to trigger re-rendering. The introduction of current mode will not affect the existing state management or the new state management The solution has any impact, but it puts higher requirements on the user's ui code, so as not to cause bugs that are difficult to eliminate because of current mode

For this reason, React also provides the React.Strict component to deliberately trigger the dual call mechanism, https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects to guide users to write more The react code conforms to the specification to adapt to the current mode provided in the future.

All new features of react are actually activated by fiber. With fiber architecture, it has derived hook, time slicing, suspense and the future Concurrent Mode, both class components and function components You can work safely in Concurrent Mode, as long as you follow the specifications.

Taken from: https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects

Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:

-Class component constructor, render, and shouldComponentUpdate methods
-Class component static getDerivedStateFromProps method
-Function component bodies
-State updater functions (the first argument to setState)
-Functions passed to useState, useMemo, or useReducer

So, React.Strict actually provides auxiliary APIs to guide users to write code that can run in Concurrent Mode. First let users get used to these restrictions, step by step, and finally launch Concurrent Mode .

Conclusion

Recoil advocates more fine-grained control of state and derived data. The demo looks simple in writing, but in fact, it is still very cumbersome after the code is large.

// define state
const numState = atom({key:'num', default:0});
const numBigState = atom({key:'numBig', default:100});
// Define derived data
const numx2Val = selector({
  key: "numx2",
  get: ({ get }) => get(numState) * 2,
});
const numBigx2Val = selector({
  key: "numBigx2",
  get: ({ get }) => get(numBigState) * 2,
});
const numSumBigVal = selector({
  key: "numSumBig",
  get: ({ get }) => get(numState) + get(numBigState),
});

// ---> Consumption status or derived data at ui
const [num] = useRecoilState(numState);
const [numBig] = useRecoilState(numBigState);
const numx2 = useRecoilValue(numx2Val);
const numBigx2 = useRecoilValue(numBigx2Val);
const numSumBig = useRecoilValue(numSumBigVal);
Enter fullscreen mode Exit fullscreen mode

Concent follows the essence of redux single state tree, respects modular management data and derived data, and at the same time relies on the ability of Proxy to complete the perfect integration of runtime dependency collection and immutability pursuit.

run({
  counter: {// Declare a counter module
    state: {num: 1, numBig: 100 }, // define state
    computed:{// Define the calculation, and determine the dependency when deconstructing the specific state in the parameter list
       numx2: ((num))=> num * 2,
       numBigx2: ((numBig))=> numBig * 2,
       numSumBig: ({num, numBig})=> num + numBig,
     }
  },
});

// ---> The consumption state or derivative data at ui, the dependency is generated only after the structure at ui
const {state, moduleComputed, setState} = useConcent('counter')
const {numx2, numBigx2, numSumBig} = moduleComputed;
const {num, numBig} = state;
Enter fullscreen mode Exit fullscreen mode

So you will get:
-Dependency collection at runtime, while also following the principle of react immutability
-Everything is a function (state, reducer, computed, watch, event...), can get more friendly ts support
-Support middleware and plug-in mechanism, easy to be compatible with redux ecology
-Supports centralized and fractal module configuration, synchronous and asynchronous module loading at the same time, which is more friendly to the flexible reconstruction process of large projects

❤ star me if you like concent ^_^

Edit on CodeSandbox
https://codesandbox.io/s/concent-guide-xvcej

Top comments (0)