This is to share with you what I think are the best practices of React hooks, based on my experience using full hook-based React Components in my projects.

Many terms in this article are designed to clarify some concepts and are not proper nouns and should not be taken seriously.

Review the React Hooks

First, a quick review of React Hooks.

First look at the traditional React class-based Component. A component consists of four parts:

  • State: a unified set of states
  • Lifecycle methods (WillMount/Mounted/WillReceiveProps/Updated/WillUnmount, etc.)
  • Handlers: Some callback method, called in the View layer, is applied to the state layer
  • The render function is the view layer of the component, responsible for producing the VirtualDOM node of the component, mounting the callback function, etc

The React Hooks component can simply be understood as a render function. The render function itself is a component. He uses the useState and useEffect functions to “stateful” functions, that is, to get the ability to register and access the state and lifecycle.

Hooks are a new set of frameworks

Compared to class components, Hooks components have the following characteristics

  • Top-down: The Hooks components that act as functions have a simpler logical flow, top-down, than the methods of a class component calling each other
  • Handlers are weakened: Although it is possible to declare callbacks in the context of functions for functional components, as opposed to method registration for class components, this is not as natural as for class methods.
  • Simplified lifecycle: Hooks register lifecycle functions based on dependent changes with a single useEffect, which mixes the lifecycle of a class component together. In fact, we could have completely abandoned our original React component lifecycle understanding and simply understood useEffect as a side effect of functions registered in render.
  • Decentralized state and Effect registration and access: Hooks no longer require that all state and lifecycle methods used by a component be registered in a single place, as is typical with class components, allowing for a more “model-cohesive” logical organization.
  • Dependency driven: Several base Hooks are designed with the concept of DEPS to implement the ability to execute declared functions based on changes to dependencies

Given the different syntax and fully parallel API described above, Hooks component writing can be considered a whole new framework independent of class-based components. We should try to avoid writing the Hooks component logic in a style that mimics class components, and should revisit the new syntax.

Because of the syntax above, Hooks are appropriate to be written in a “change-based” declarative style rather than a “callbacks based” declarative style. This makes it easier for a component to split and reuse logic and have clearer logical dependencies. You’ll see the benefits of the “change-based” style. Here are two examples to compare “change-based” and “callbacks” :

Example 1: Declare a request using useEffect

Requirement scenario: Change a keyword state and initiate a query request

Callback based writing (copycat writing)

const Demo: React.FC = () = > {
    const [state, setState] = useState({
        keyword: ' '});const query = useCallback((queryState: typeof state) = > {
        // ...} []);const handleKeywordChange = useCallback((e: React.InputEvent) = > {
        constlatestState = { ... state,keyword: e.target.value };
        setState(latestState);
        query(latestState);
    }, [state, query]);
    return // view
}
Copy the code

There are several problems with this notation:

  • If handleKeywordChange is called multiple times between two renderings, the state will be too old. The resulting latestState will not be up to date and will cause bugs.(This problem class component will also exist)
  • The query method needs to be called imperative each time in the handler. If more handlers need to call it, the dependency syntax is complex and it is easy to forget to call it manually.
  • The queryState used by query is the latest state, but each time the handler needs to calculate the state to query function, the responsibility between methods is not clear.

Change based writing

const Demo: React.FC = () = > {
    const [state, setState] = useState({
        keyword: ' '});const handleKeywordChange = useCallback((e: React.InputEvent) = > 
        {
            const nextKeyword = e.target.value;
            setState(prev= > ({ ...prev, keyword: nextKeyword }))
        }, []);
    useEffect(() = > {
        // query
    }, [state]);
    return // view
}
Copy the code

The above method solves all the problems of callbacks. It uses state as a dependency on the Query. Whenever the state changes, the query automatically executes, and the execution time must be after the state changes. Instead of calling query imperatively, we declare under what circumstances it should be called.

Of course, this is not without problems:

  • If the requirement scenario requires that we not trigger the Query when certain fields of state change, this would be invalid

In fact, this is precisely the problem that requires us to focus more on the management of changes and fixes when we write them, rather than on the management of Hooks and dishooks.

Example 2: Register the listener on window size

Requirement scenario: The callback function is fired on window resize

Callback based writing (copycat writing)

const Demo: FC = () => { const callback = // ... useEffect(() => { window.addEventListener('resize', callback); return () => window.removeEventListener('resize', callback); } []); return // view }Copy the code

Register the listener when “componentDidMount” and log it off when “componentWillUnmount”. Simple, isn’t it?

The problem, however, is that in a class component, a callback can be a class method whose reference does not change throughout the lifetime of the component. But the callback in a functional component is generated in the context of each execution, and it will most likely be different each time! Thus, the listener mounted on the window object will be the callback generated by the component’s first execution, and all subsequent callbacks will not be mounted to the subscriber of the window, and the bug will occur.

How about a change?

Callback-based writing method 2
const Demo: FC = () = > {
    const callback = // ...
    useEffect(() = > {
        window.addEventListener('resize', callback);
        return () = > window.removeEventListener('resize', callback);
    }, [callback]);
    return // view
}
Copy the code

Putting the callback into the effect dependency of the registered listener might seem to work, but it’s too inelegant. During the execution of the component, we will be frantically registering and unregistering the Window object, which sounds unreasonable. Here’s how to write it based on change:

Change based writing
const Demo: FC = () = > {
    const [windowSize, setWindowSize] = useState([
        window.innerWidth,
        window.innerHeight
    ] as const);
    useEffect(() = > {
        const handleResize = () = > {
            setWindowSize([window.innerWidth, window.innerHeight]);
        }
        window.addEventListener('resize', handleResize);
        return () = > window.removeEventListener('resize', handleResize); } []);const callback = // ...
    useEffect(callback, [windowSize]);
    return // view
};
Copy the code

Here we first convert the window resize from a callback to a state representing the window size using a useState and a useEffect. Later changes that rely on this state implement the call to the callback. This call is also declarative, rather than a direct manual imperative call, and declarative tends to mean better testeadability.

The above code looks a bit more complicated, but in fact, once we pull out lines 2-10, we quickly get a custom Hooks: useWindowSize that can be reused across components. Makes it easy to use window resize-based callbacks in other components:

const useWindowSize = () = > {
    const [windowSize, setWindowSize] = useState([window.innerWidth, window.innerHeight] as const);
    useEffect(() = > {
        const handleResize = () = > {
            setWindowSize([window.innerWidth, window.innerHeight]);
        }
        window.addEventListener('resize', handleResize);
        return () = > window.removeEventListener('resize', handleResize); } []);return windowSize
}
Copy the code

The key to the change-based approach is the transition from action to state

Marble Diagrams

From the above discussion and examples, we can see that the rational use of change-based code in hook-based components can have certain benefits. To better understand this “based on change” thing. The Marble diagram, often used in streaming programming to aid understanding, is introduced here. You’ll soon find that what we’ve been talking about as “change based” is the same as “flow” in streaming programming:

RxMarble legend

In streaming programming, a bead _ (marble) _ represents an incoming data stream, and a string of horizontal beads represents a data stream (ObservableSubject) in time. Streaming programming uses a series of operators to process, integrate and map data streams to realize programming logic. The merge operation, shown in the figure above, is a very common operator for merging two data sources.

Immutable Data Streams and “execute frames”

Hooks coding based on changes is actually quite isomorphic to stream coding. Both weaken the callback, wrapping the callback as a stream or operator.

A state in the Hooks component is a stream in streaming programming, which is a string of beads

Each change of a state is a bead

Data streams are imaflow

In order to fully represent changes, all state updates are immutable. In short, a reference changes exactly as a value changes

To achieve this, you can:

  1. Notice every time you setState
  2. Implement some immutable utils yourself
  3. Use third-party data structure libraries such as Facebook’s ImmutableJS

(Personally recommend 1 or 2 to minimize the introduction of unnecessary concepts)

Perform the frame

In hook-based programming, we also have the concept of what is called an “execution frame.” This concept is weakened in other frameworks such as Vue/Angular, but it is useful to think about in React, especially in functional componentsThe execution of the component is triggered when state or props change in the context of the component. Each execution is equivalent to a rendering frame. All marble is strung in a grid of execution frames and states

Source of change

For a component, a change that triggers it to rerender is called a “source.” There are several common change sources for a component:

  • Props change: The props passed by the parent component to the component changed
  • Event: Such as click, such as the window resize event above. For events, you need to wrap the event callback as state
  • Scheduler: animationFrame/Interval/timeout

Some of these sources, such as props, have been marble – ified. Some of them are not yet, we need to “marble” them in the way of packaging.

Example 1: Wrapping an event

const useClickEvent = () = > {
    const [clickEvent, setClickEvent] = useState<{ x: number; y: number; } > (null);
    const dispatch = useCallback((e: React.MouseEvent) = > {
        setClickEvent({ x: e.clientX, y: e.clientY }); } []);return [clickEvent, dispatch] as const;
}
Copy the code

Example 2: Wrapping the scheduler (for example, interval)

const useInterval = (interval: number) = > {
    const [intervalCount, setIntervalCount] = useState();
    useEffect(() = > {
        const intervalId = setInterval(() = > {
            setIntervalCount(count= > count + 1)});return () = > clearInterval(intervalId); } []);return intervalCount;
};
Copy the code

Streaming operator

The data organization of a component can be abstracted as follows from the data state required from the source change to the final View layer:Operators in the middle are the core logic for the component to process the data. In streaming programming, operators can almost always write isomorphic representations in Hooks via custom Hooks.

These “streaming Hooks” are a combination of basic Hooks into higher-order Hooks that can be highly reusable, making the code logic simpler.

Mapping (map)

With useMemo, you can directly implement a “computed” state by combining changes together

Corresponding to ReactiveX concept: map/combine/latestFrom

const [state1, setState1] = useState(initalState1);
const [state2, setState2] = useState(initialState2);
const computedState = useMemo(() = > {
    return Array(state2).fill(state1).join(' ');
}, [state1, state2]);
Copy the code

Skip the first few times/take only the first few times

Sometimes we don’t want to execute the functions in Effect the first time, or do a computed map. Can realize their own implementation of useCountEffect/useCountMemo to achieve

ReactiveX concept: take/skip

const useCountMemo = <T>(callback: (count: number) = > T, deps: any[]): T= > {
    const countRef = useRef(0);
    return useMemo(() = > {
        const returnValue = callback(countRef.current);
        countRef.current++;
        return returnValue;
    }, deps);
};
export const useCountEffect = (cb: (index: number) => any, deps? : any[]) = > {
    const countRef = useRef(0);
    useEffect(() = > {
        const returnValue = cb(countRef.current);
        currentRef.current++;
        return returnValue;        
    }, deps);
};
Copy the code

Process and Scheduling (debounce/throttle/delay)

In the change-based Hooks component, operations such as debounce/throttle/delay become very simple. The object for debounce/throttle/delay will no longer be the callback function itself, but the changed state

This parameter corresponds to the ReactiveX concept: debounce, delay, and throttle

const useDebounce = <T>(value: T, time = 250) = > {
    const [debouncedState, setDebouncedState] = useState(null);
    useEffect(() = > {
        const timer = setTimeout(() = > {
            setDebouncedState(value);
        }, time);
        return () = > clearTimeout(timer);
    }, [value]);
    return debouncedState;
};
const useThrottle = <T>(value: T, time = 250) = > {
    const [throttledState, setThrottledState] = useState(null);
    const lastStamp = useRef(0);
    useEffect(() = > {
        const currentStamp = Date.now();
        if (currentStamp - lastStamp > time) {
            setThrottledState(value);
            lastStamp.current = currentStamp;
        }
    }, [value]);
    return throttledState
}
Copy the code

Asynchronous processes in action/Reducer mode

Redux’s core action/Reducer mode is very simple to implement in Hooks. React even provides a useReducer, a encapsulated syntax sugar hook, to implement this mode.

For asynchronous processes, we can also use action/Reducer mode to implement a useAsync hook to help us deal with asynchronous processes.

The simplest promise-based function pattern suggested here is similar to the redux-Thunk middleware used in Redux.

At the same time, we maintain a set of loading/Error/Ready fields with the data state of the request, indicating the current data state.

The useAsync hook can also build in control logic for mechanisms such as race/order preservation/automatic cancellation of multiple asynchronous processes.

The following is an example of the use of the useAsync hook, using a generator to implement a multi-step state modification in an asynchronous process. You can even implement complex asynchronous process management like Redux-Saga.

const responseState = useAsync(responseInitialState, actionState, function * (action, prevState) {
    switch(action? .type) {case 'clear':
            return null;
        case 'request': {
            const { data } = yield apiService.request(action.payload);
            return data;
        }
        default:
            returnprevState; }})Copy the code

The following code illustrates a scenario where the data state of a dictionary type is maintained using asynchronous hooks in the action/ Reducer mode:

// Actions from props or state
// Fetch action: fetch action
let fetchAction: {
  type: 'query'.id: number;
};

let clearAction: {
  type: 'clear'.ids: number[]; // Ids to be retained
}

let updateAction: {
  type: 'update'.id: number;
}

// Use a custom merge hook to retain the last of the three states
const actions = useMerge(fetchAction, clearAction, updateAction);

// reducer
const dataState = useQuery(
    {} as Record<number, DataType>,
    actions,
    async (action, prev) => {
        switch(action? .type) {case 'update':
            case 'query': {
                const { id } = action;
                // If the sublist already exists, return an identity function without making changes to the data
                if (action.type === 'query' && prev[id]) return prevState= > prevState;
                // Pull the list data under the specified ID
                const { data } = await httpService.fetchListData({ id });
                // Return a state mapping function to insert data
                return prev= > ({
                    ...prev,
                    [id]: data,
                });
            }
            case 'clear': {
                // Return a state mapping function that retains the specific ID data
                return prev= >
                    pick( // Pick is a method that takes a number of key and value pairs from an object to form a new object
                        prev,
                        action.ids,
                    );
            }
            default:
                returnprev; }}, {mode: 'multi'.immediate: false});Copy the code

Singleton Hooks — global state management

You can use Hooks to manage global state in the same way as you would use a Provider to deliver global state, for example, with context and redux. Here’s a more convenient way to do it: singleton Hooks: Hox

The createModel method provided by the third-party library Hox generates an Hooks that are mounted to a global singleton in a virtual component. This virtual component instance, once created, will survive the entire life of the app, creating a global “marble source” that any component can use to process its own logic.

The specific implementation of hoX involves customizing the React Reconciler, and interested students can take a look at its source code implementation.

Limitations of stream Hooks

I also refer to it as “stream Hooks” because it is so similar to streaming programming.

Many of the benefits of streaming Hooks are described above. With proper logic splitting and reuse, stream Hooks can implement very fine-grained and highly cohesive code logic. It is also proved to be easy to maintain in long-term practice. So what are the limitations of these style Hooks?

“Too frequent” changes

In React, there are three different “frame rate” or “frequency” things:

  • Reconcile: Synchronize virtualDOM changes to the real DOM
  • Execution frame Rendering: The frequency at which the React component is executed
  • Event: Indicates the frequency of event dispatches

All three of these trigger more and more frequently from top to bottom

Since the minimum granularity of React Hooks changes propagating is the “execution frame” granularity, once events occur more frequently than this (which is typically only synchronized multiple events), this style of Hooks requires some Hack logic to handle them.

Avoid “flow for flow’s sake”

Streaming programming such as RxJS is heavily used for message communication (as in Angular) and for handling complex event flows. But it has not become a mainstream application architecture itself. A bottleneck in this situation is that there is almost no way to write an element of imperative code, resulting in a situation where code that is well implemented by imperative/callback becomes very verbose and difficult to understand.

Although React Hooks are largely isomorphic to the syntax of RxJS, they are essentially imperative low-level programming, so they can be multi-paradigm. In coding, we can implement streaming style in most scenarios, but we should avoid streaming for the sake of streaming. For example, a comment under Redux on which states should be placed globally and which should be placed within the component Issue: choose the one that looks less weird

vision

I am currently planning and producing a set of basic streaming Hooks that can be referenced by business logic to write Marble Hooks code with a streaming style

❤️ Thanks for your support

  1. If you like it, don’t forget to share it, like it and watch it again.