A recent attempt to write a login form using the React hooks API was designed to improve your understanding of hooks. This article does not cover the use of specific apis, but rather provides step-by-step instructions for the functionality to be implemented. So get a basic understanding of hooks before you read. The end result is something like writing a simple Redux-like state management pattern with hooks.

Fine-grained state

A simple login form contains three input items, namely username, password and verification code, and also represents the three data states of the form. We simply establish state relations for username, password and capacha respectively through useState, which is the so-called fine-grained state division. The code is also simple:

// LoginForm.js

const LoginForm = (a)= > {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [captcha, setCaptcha] = useState("");

  const submit = useCallback((a)= > {
    loginService.login({
      username,
      password,
      captcha,
    });
  }, [username, password, captcha]);

  return(<div className="login-form"> <input placeholder=" username "value={username} onChange={(e) => {setUsername(e.target.value);  }} /> <input placeholder=" password "value={password} onChange={(e) => {setPassword(e.target.value); }} /> <input placeholder=" value "value={captcha} onChange={(e) => {setCaptcha(e.target.value); }} / > < button onClick = {submit} > submit < / button > < / div >). }; export default LoginForm;Copy the code

Such fine-grained states are simple and intuitive, but writing the same logic for each state would be cumbersome and diffuse.

coarse-grained

We define username, password and Capacha as a state, which is the so-called coarse-grained state division:

const LoginForm = () => {
  const [state, setState] = useState({
    username: "",
    password: "",
    captcha: ""}); const submit = useCallback(() => { loginService.login(state); }, [state]);return (
    <div className="login-form">
      <input
        placeholder="Username"
        value={state.username}
        onChange={(e) => {
          setState({ ... state, username: e.target.value, }); }} / >... <button onClick={submit}> </button> </div>); };Copy the code

As you can see, the setXXX method is reduced and the setState is better named, but the setState does not automatically merge the state items, so we need to merge them manually.

Join form verification

A complete form must have validation. To display an error message below the input if an error occurs, we first extract a child component called Field:

const Filed = ({ placeholder, value, onChange, error }) => {
  return (
    <div className="form-field">
      <input placeholder={placeholder} value={value} onChange={onChange} />
      {error && <span>error</span>}
    </div>
  );
};

Copy the code

We use the schema-typed library to do some field definitions and validation. It’s easy to use, the API works like the React PropType, and we define the following fields for validation:

const model = SchemaModel({
  username: StringType().isRequired("User name cannot be empty"),
  password: StringType().isRequired("Password cannot be empty."),
  captcha: StringType()
    .isRequired("Verification code cannot be empty.")
    .rangeLength(4.4."Verification code is 4 characters")});Copy the code

Errors is then added to state and model.check is triggered in the Submit method for validation.

const LoginForm = (a)= > {
  const [state, setState] = useState({
    username: "".password: "".captcha: "".// ++++
    errors: {
      username: {},
      password: {},
      captcha: {},}});const submit = useCallback((a)= > {
    const errors = model.check({
      username: state.username,
      password: state.password,
      captcha: state.captcha, }); setState({ ... state,errors: errors,
    });

    const hasErrors =
      Object.values(errors).filter((error) = > error.hasError).length > 0;

    if (hasErrors) return;
    loginService.login(state);
  }, [state]);

  return (
    <div className="login-form">
      <Field
        placeholder="Username"
        value={state.username}
        error={state.errors["username"].errorMessage}
        onChange={(e)= >{ setState({ ... state, username: e.target.value, }); }} / >...<button onClick={submit}>submit</button>
    </div>
  );
};
Copy the code

We then click Submit without typing anything, and an error message is triggered:

UseReducer rewrite

At this point, our form feels almost complete. But is that ok? We print console.log(placeholder, “rendering”) in the Field component, and when we input the user name, we see that all the Field components are rerendered. This is something you can try to optimize.

React.memo

React.memo is a higher-order component. It is very similar to the React.PureComponent, but only for function components. If your function component is rendering the same results given the same props, you can improve the performance of the component by wrapping it in a react.Memo call to remember the results of the component’s rendering

export default React.memo(Filed);

But just like that, the Field component is completely rerendered. This is because our onChange function returns a new function object each time, making the Memo invalid. Filed onChange functions can be wrapped in useCallback so that they don’t have to produce a new function object every time the component is rendered.

const changeUserName = useCallback((e) => {
  const value = e.target.value;
  setState((prevState) => {// Note that since we set useCallback dependency to null, we use function form to get the latest State(preState).return{... prevState, username: value, }; }); } []);Copy the code

Are there any other solutions? We have noticed that useReducer,

UseReducer is another option that is better suited for managing state objects with multiple child values. It is an alternative to useState. It receives a Reducer of the form (state, action) => newState and returns the current state and its accompanying dispatch method. Also, using useReducer can optimize performance for components that trigger deep updates because you can pass dispatches to child components instead of callbacks

An important feature of useReducer is that the identity of the dispatch function it returns is stable and does not change when the component is re-rendered. Then we can safely pass the Dispatch to the child component without worrying about causing the child component to re-render. We first defined the reducer function, which was used to operate state:

const initialState = {
  username: "". errors: ... }; // dispatch({type: 'set', payload: {key: 'username', value: 123}})
function reducer(state, action) {
  switch (action.type) {
    case "set":
      return {
        ...state,
        [action.payload.key]: action.payload.value,
      };
    default:
      returnstate; }}Copy the code

Accordingly, userReducer was called in LoginForm and our Reducer function and initialState were passed in

const LoginForm = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const submit = ...

  return (
    <div className="login-form">
      <Field
        name="username"
        placeholder="Username"
        value={state.username}
        error={state.errors["username"].errorMessage} dispatch={dispatch} /> ... <button onClick={submit}> </button> </div>); };Copy the code

Add the name attribute in the Field child to identify the updated key and pass in the Dispatch method

const Filed = ({ placeholder, value, dispatch, error, name }) => {
  console.log(name, "rendering");
  return (
    <div className="form-field">
      <input
        placeholder={placeholder}
        value={value}
        onChange={(e) =>
          dispatch({
            type: "set",
            payload: { key: name, value: e.target.value },
          })
        }
      />
      {error && <span>{error}</span>}
    </div>
  );
};

export default React.memo(Filed);
Copy the code

Instead of passing in the onChange function, we let the child component handle the change event internally by passing in dispatch. Meanwhile, the state management logic of the form was migrated to the Reducer.

Global store

When we were deep in the component hierarchy and wanted to use the dispatch method, we needed to pass layers of props, which was obviously inconvenient. You can use the React Context API to share states and methods across components.

Context provides a way to pass data across the component tree without manually adding props for each layer of components

Functional components can be implemented using createContext and useContext.

We’re not going to talk about how to use these two apis, but you can basically write them out in the documentation. We used unstated-next to achieve this, which is essentially a encapsulation of the above API and makes it easier to use.

We start with a new store.js file, place our reducer function, and create a useStore hook that returns the state and dispatch we care about, then call createContainer and expose the return value store to external files.

// store.js
import { createContainer } from "unstated-next";
import { useReducer } from "react";

const initialState = {
  ...
};

function reducer(state, action) {
  switch (action.type) {
    case "set":... default:returnstate; }}function useStore() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return { state, dispatch };
}

export const Store = createContainer(useStore);
Copy the code

Then we wrap the LoginForm layer Provider

// LoginForm.js
import { Store } from "./store";

const LoginFormContainer = () => {
  return (
    <Store.Provider>
      <LoginForm />
    </Store.Provider>
  );
};
Copy the code

This makes state and Dispatch freely accessible in child components via useContainer

// Field.js
import React from "react";
import { Store } from "./store";

const Filed = ({ placeholder, name }) => {
  const { state, dispatch } = Store.useContainer();

  return (
    ...
  );
};

export default React.memo(Filed);
Copy the code

You can see how easy it is to access state and Dispatch regardless of the component hierarchy. But then the state changes after each call to Dispatch, causing the Context to change, and the child component will be rerendered, even if I only update username and wrap the component with memo.

When the most recent <MyContext.Provider> update is made to the component’s upper layer, the Hook triggers a rerender and uses the latest context value passed to MyContext Provider. Even if the ancestor uses React. Memo or shouldComponentUpdate, it will be rerendered when the component itself uses useContext

// Field. Js const Filed = ({placeholder, error, name, dispatch, value}) => { State} const FiledInner = react.memo (Filed); // Const FiledContainer = (props) => {const {state, dispatch} = store.usecontainer (); const value = state[props.name]; const error = state.errors[props.name].errorMessage;return( <FiledInner {... props} value={value} dispatch={dispatch} error={error} /> ); };export default FiledContainer;
Copy the code

This means that the Field component will not be re-rendered while the value remains unchanged. We can also abstract a high-order component like Connect to do this:

// Field.js
const connect = (mapStateProps) => {
  return (comp) => {
    const Inner = React.memo(comp);

    return (props) => {
      const { state, dispatch } = Store.useContainer();
      return( <Inner {... props} {... mapStateProps(state, props)} dispatch={dispatch} /> ); }; }; };export default connect((state, props) => {
  return {
    value: state[props.name],
    error: state.errors[props.name].errorMessage,
  };
})(Filed);
Copy the code

Dispatch a function

With Redux, I tend to write some logic into functions such as dispatch(login()), which makes dispatch support asynchronous actions. This functionality is also easy to implement by simply decorating the Dispatch method returned by useReducer.

// store.js
function useStore() {
  const [state, _dispatch] = useReducer(reducer, initialState);

  const dispatch = useCallback(
    (action) => {
      if (typeof action === "function") {
        return action(state, _dispatch);
      } else {
        return _dispatch(action);
      }
    },
    [state]
  );

  return { state, dispatch };
}
Copy the code

Before calling the _dispatch method, we check the action passed in and pass state and _dispatch as arguments if the action is a function. Finally, we return the decorated Dispatch method.

I don’t know if you noticed that the dispatch function here is unstable because it relies on state, and every time the state changes, the dispatch changes. This causes the component whose props are Dispatch to be rerendered each time. This is not what we want, but if the state dependency is not written, then the latest state is not available inside the useCallback.

Is there a way to get the latest state without writing state to deps? Hook also provides a solution, that is, useRef

The ref object returned by useRef remains constant throughout the life of the component, and changing the ref’s current property does not cause the component to re-render

With this feature, we can declare a ref object and assign current to the latest state object in useEffect. So in our decorated dispatch function we can get the latest state through ref.current.

// store.js
function useStore() {
  const [state, _dispatch] = useReducer(reducer, initialState);

  const refs = useRef(state);

  useEffect(() => {
    refs.current = state;
  });

  const dispatch = useCallback(
    (action) => {
      if (typeof action === "function") {
        returnaction(refs.current, _dispatch); //refs.current gets the latest state}else {
        return_dispatch(action); }}, [_dispatch] // _dispatch itself is stable, so our dispatch can also remain stable);return { state, dispatch };
}
Copy the code

We can then define a login method as an action, as follows

// store.js
export const login = () => {
  return (state, dispatch) => {
    const errors = model.check({
      username: state.username,
      password: state.password,
      captcha: state.captcha,
    });

    const hasErrors =
      Object.values(errors).filter((error) => error.hasError).length > 0;

    dispatch({ type: "set", payload: { key: "errors", value: errors } });

    if (hasErrors) return;
    loginService.login(state);
  };
};
Copy the code

In LoginForm, we can call Dispatch (login()) directly when we submit the form.

const LoginForm = () => { const { state, dispatch } = Store.useContainer(); .return (
  <div className="login-form">
    <Field
      name="username"
      placeholder="Username"/ >... < button onClick = {() = > dispatch (the login ())} > submit < / button > < / div >). }Copy the code

A dispatch that supports asynchronous action is complete.

conclusion

As you can see here, we implement a simple Redux-like state management pattern using the hooks capability. There is no universally accepted pattern for hooks state management, and there is still room for fiddling. Facebook recently launched Recoil, which you can check out when you’re free. In many cases above, we wrote a lot of logic to avoid re-rendering of subcomponents, including using useCallback, memeo, and useRef. These functions themselves consume a certain amount of memory and computing resources. Render is actually very cheap for modern browsers, so sometimes we don’t need to do these optimizations in advance, but this is for learning purposes only. Check out the Ali Hooks library at some point and learn a lot about the use of hooks and marvel at the fact that hooks can abstract out so much generic business irrelevant logic.

Complete code for this article

Reference:

Do you really have React Hooks right? Get a thorough understanding of the Rendering logic of React Hooks data Stream with 10 examples