Handwriting a mini version of React state management tool

Handwriting a mini version of React state management tool

Currently, there are many various state management tools in React, such as:

Each state management tool has different APIs and usage methods, and all have a certain learning cost. Moreover, these state management tools also have a certain degree of complexity and are not extremely simple. In the eyes of developers, only if it is easier to use, more people will use it. Isn’t Vue popular because it is easy to use and quick to get started?

Sometimes we only need a global state, and it is enough to place some states and functions to change the state, which also achieves the principle of simplicity.

Let’s implement the simplest state management tool together.

The core of this state management tool uses the Context API . Before understanding this article, you must first understand and be familiar with the usage of this API.

First, let’s look at how to use this status management tool. Suppose there is a counter state, and then we modify the counter through two methods, that is, addition and subtraction. In other words, we need to use a counter state and two methods to modify this state. In the React function component, we use the useState method to initialize a state. Therefore, we can easily write the following code:

import { useState } from  'react' 
const  useCounter = ( initialCount = 0 ) => {
     const [count,setCount] = useState (initialCount);
     const  increment = () => setCount (count + 1 );
     const  decrement = () => setCount (count - 1 );
     return {
        count,
        increment,
        decrement
    }
}
export  default useCounter;

Now, let’s create a component to use this useCounter hook function, as follows:

import  React  from  'react' 
import useCounter from  './useCounter'

const  Counter = () => {
     const { count,increment,decrement } = useCounter ();
     return (
         < div  className = "counter" >
            { count }
            < button  type = "button"  onClick = {increment} > add </ button > 
            < button  type = "button"  onClick = {decrement} > plus </ button > 
        </ div >
    )
}

Then use it in the root component App, as follows:

import  React  from  'react' 
const  App = () => {
     return (
         < div  className = "App" > 
            < Counter /> 
            < Counter /> 
        </ div >
    )
}

In this way, a counter component is completed, but is it really just that?

First of all, we should know that the state of the counter component should be consistent, that is to say, our counter component should share the same state, so how to share the same state? This is when Context needs to appear. To transform the above components, we initialize the state in the root component App and pass it to the sub-components. First modify the code of the App root component as follows:

Create a new CounterContext.ts file with the following code:

const  CounterContext = createContext ();
 export  default  CounterContext ;
import  React ,{ createContext } from  'react' 
import  CounterContext  from  './CounterContext'

const  App = () => {
     const { count,increment,decrement } = useCounter ();
     return (
         < div  className = "App" > 
            < CounterContext.Provider  value = {{count,increment,decrement}} > 
                < Counter /> 
                < Counter / > 
            </ CounterContext.Provider > 
        </ div >
    )
}

Then we also modify the Counter component code as follows:

import  React ,{ useContext } from  'react' 
import  CounterContext  from  './CounterContext'

const  Counter = () => {
     const { count,increment,decrement } = useContext ( CounterContext );
     return (
         < div  className = "counter" >
            { count }
            < button  type = "button"  onClick = {increment} > add </ button > 
            < button  type = "button"  onClick = {decrement} > plus </ button > 
        </ div >
    )
}

In this way, we can share the count state, no matter how deep it is used in sub-components, there is no problem, but this is not the end, let us continue.

Although using this method solves the problem of shared state, we found that we need to pass in an additional context name when using it, so we need to wrap it up. In the end, we only need to use it like the following:

const  Counter = createModel (useCounter);
 export  default  Counter ;
const { Provider, useModel } = Counter;

Then our App component should look like this:

import  React ,{ createContext } from  'react' 
import counter from  './Counter'

const  App = () => {
     const { Provider } = counter;
     return (
         < div  className = "App" > 
            < Provider > 
                < Counter /> 
                < Counter /> 
            </ Provider > 
        </ div >
    )
}

Continue to modify our Counter component as follows:

import  React ,{ useContext } from  'react' 
import counter from  './Counter'

const  Counter = () => {
     const { count,increment,decrement } = counter. useModel ();
     return (
         < div  className = "counter" >
            { count }
            < button  type = "button"  onClick = {increment} > add </ button > 
            < button  type = "button"  onClick = {decrement} > plus </ button > 
        </ div >
    )
}

Through the display of the above code, we actually understand that we are just building useContext and createContext into our encapsulated Model.

Next, we will unveil the mystery of this state management tool. First, we need to use the React-related API, so we need to import it. as follows:

// Import type 
import  type { ReactNode , ComponentType } from  'react' ;
 import { createContext,useContext } from  'react' ;

Next define a unique identifier that is used to determine the incoming Context, and this is used to determine that the user is using the Context correctly.

const  EMPTY :unique symbol = Symbol ();

Next we need to define the type of Provider. as follows:

export interface  ModelProviderProps < State = void > {
    initialState?: State
    children: ReactNode
}

Above we defined the state type of context, which is a generic type. The parameter is the type of state. It is initialized to undefined type by default, and a children type is defined. Because the child node of Provider is a React node, it is defined as ReactNode. type.

Then there is our Model type, as follows:

export interface  Model < Value , State = void > {
    Provider: ComponentType<ModelProviderProps<State>>
    useModel: () => Value
}

This is also easy to understand, because Model exposes two things, the first is Provider, and the second is useContext. It just changes the name. It is enough to define the types of these two.

Next is the implementation of our core function createModel function. Let’s go step by step. The first thing is to define this function. Pay attention to the type, as follows:

export  const createModel = <Value,State = void >(useHook:(initialState?:State) => Value): Model<Value,State> => {
     //Core code 
}

What is difficult to understand about the above functions is the definition of the type. Our createModel function passes in a hook function. The hook function passes in a state as a parameter, and then the return value is the Model generic we defined. The parameter is the type we defined. Function generics.

Next, what we have to do is naturally create a context, as follows:

//Create a context 
const context = createContext<Value | typeof EMPTY >( EMPTY ) ;

Then we need to create a Provider function, which is essentially a React component, as follows:

const  Provider = ( props:ModelProviderProps<State> ) => {
     // The main reason why ModelProvider is used here is that it cannot conflict with the defined function name 
    const { Provider : ModelProvider } = context;
     const { initialState,children } = props;
     const value = useHook (initialState);
     return (
         < ModelProvider  value = {value} > {children} </ ModelProvider >
    )
}

It is also easy to understand here. In fact, we get the initial state and child nodes through the parent component, get the Provider component from the context, and then return it. Note that our value is the value wrapped by the custom hook function passed in.

In the third step, we need to define a hook function to get this custom Context, as follows:

const useModel = (): Value => {
     const value = useContext (context);
     // Here, make sure the user is using Provider correctly 
    if (value === EMPTY ){
         //Exception is thrown, the user is not wrapped in Provider Component 
        throw  new  Error ( 'Component must be wrapped with <Container.Provider>' );
    }
    //Return context 
    return value;
}

The implementation of this function is also easy to understand, which is to obtain the context, determine whether the context is used correctly, and then return.

Finally, we return these two things inside this function, that is, return the Provider and useModel functions. as follows:

return { Provider, useModel }

Combine all the above codes, and the createModel function is complete.

Finally, we merge all the code and the state management tool is completed.

// Import type 
import  type { ReactNode , ComponentType } from  'react' ;
 import { createContext,useContext } from  'react' ;
 const  EMPTY :unique symbol = Symbol ();
 export  interface  ModelProviderProps < State = void > {
    initialState?: State 
    children : ReactNode
}
export  interface  Model < Value , State = void > {
     Provider : ComponentType < ModelProviderProps < State >>
     useModel : () =>  Value
}
export  const createModel = < Value , State = void >( useHook : ( initialState?:State ) =>  Value ): Model < Value , State > => {
     //Create a context 
    const context = createContext< Value | typeof  EMPTY >( EMPTY );
     // Define Provider function 
    const  Provider = ( props:ModelProviderProps<State> ) => {
         const { Provider : ModelProvider } = context;
         const { initialState,children } = props;
         const value = useHook (initialState);
         return (
             < ModelProvider  value = {value} > {children} </ ModelProvider >
        )
    }
    //Define the useModel function 
    const useModel = (): Value => {
         const value = useContext (context);
         // Here determine whether the user is using the Provider correctly 
        if (value === EMPTY ){
             //Throw an exception, the user The component is not wrapped with Provider 
            throw  new  Error ( 'Component must be wrapped with <Container.Provider>' );
        }
        //Return context 
        return value;
    }
    return { Provider ,useModel };
}

Going a step further, we export a useModel function, as follows:

export  const useModel = <Value,State = void >(model:Model<Value,State>):Value => {
     return model.useModel();
}

So far, our entire state management tool is complete, and it is very simple to use. In many lightweight shared state projects, we no longer need to use more complex state management tools like Redux.

Of course, this idea is not my own. The source has been indicated at the end of the article. This article analyzes the source code.