Use Hooks or Redux to Manage State in React.js

2020年09月05日 1035Browse 1Like 0Comments

Redux

Redux is a predictable state container for JavaScript apps.

As state in application becomes increasingly complicated for SAP, there might be more different types of state to manage: local created data which is not persisted to the server, UI data like UI elements properties, routes, spinners, paginations which determines the UI display; and server returned and cached data, etc. Redux attempts to make state mutations predictable for complicated application state by imposing certain restrictions on how and when updates can happen. These restrictions are reflected in the three principles of Redux.

Three principles of Redux

  1. Single source of truth for application state: The global state of application is stored in an object tree within a single store;

  2. State is read-only: The state cannot be modified directly by views or network callbacks, the right way to mutate is to dispatch an action to notify the reducer to process. As all changes are centralised and happen one by one in a strict order, there are no subtle race conditions to watch out for;

  3. Changes are made with pure functions: Change state via reducers. A reducer is a pure function with two characteristics:

    1. It returns same result when the input parameters are the same; the only dependency of the returned data are the input parameters, and the result is consistent regardless of where/when it is called.
    2. no side-effects: the function will not do jobs like: modify external data, render DOM elements, execute network requests, IO operations.

    These characteristics provides assurance to make state predictable.

Three core concepts of Redux

  • Actions: an action is a JavaScript object which defines what the action is, it must contain a type property to name the action, other properties on action object are defined according to the business scenarios. we usually create an action by writing an action creator function.

      function addAction() {
      	return { type: 'ACTION_TYPE_ONE', prop1: 'prop1-value', prop2: 'prop2-value' }
      }
      

  • Reducers: a reducer is a pure function which processes state when getting a dispatched action from components.

       (state, action) => newState
      

    The reducer function receives two arguments: the initial state(should be immutable) and the dispatched action, it returns the new state to store according to action types:

      function reducer(state = initialState, action) {
          switch(action.type) {
              case ACTION_TYPE_ONE:
                  // avoid to modify the state directly, return a new state value via
                  // using ES6 spread operator(...) or function:
                  // Object.assign({}, state, prop1: new_value) function 
                  return {...state, prop1: new_value};  
              case ACTION_TYPE_TWO:
                  return {...state, prop2: new_value};
              ...
              default: 
              	return state;
          }
      }
      

    When the state object becomes complex, we can divide actions by categories and assign different reducers to manage them. redux combine them into an entry/root reducer.

      import { combineReducers } from 'redux';
      // combineReducers return a boss reducer function
      const rootReducer = combineReducers({
      	reducer1,  // manages state.categoryA of category A actions
      	reducer2   // manages state.categoryB of category B actions
      });
      
      // the above code is equivelent to the following
      const rootReducer(state = {}, action) {
          return {
              reducer1: reducer1(state.categoryA, action);
              reducer2: reducer2(state.categoryB, action)
          }
      }
      

  • store: store is a JavaScript object which bridges action and reducer, it takes the following responsibilities:

    • saving and maintaining the application's global state;
    • accessing application state via getState() function, new state can be used by useState() hook to render view;
    • sending new action via dispatch(action) function;
    • monitoring application state changes via subscribe(handler) function in component, handler is a callback function which always uses getState() to fetch new state and then does other page render;

    import { createStore } from 'redux';
    let store = createStore(rootReducer, initialState);  // create the App store
    

Data flow of Redux

components ----store.dispatch(action) send action ----> reducer(state, action) return state ----> store

Steps to use Redux

npm install redux

  1. create actions with unique type values
  2. create reducers for returning new state when receiving actions
  3. create a store to use reducers to manage state with actions
  4. register state monitor via store.subscribe(cb) in component
  5. send action by dispatch(action), the reducers will process the action and return a new state to store
  6. use getState() to fetch the new state as the state is already subscribed to be monitored by store.subscribe()
  7. unsubscribe the state monitor

Use Redux(react-redux) in React

react-redux is a middleware providing more convenience for React.js apps to manage state in a redux store. It's the official React bindings for Redux, integrated some encapsulated methods and providing better performance for using Redux.

npm install redux react-redux

  • Step 1: Create reducer(s): Create app reducer for processing actions and update state.

    // reducer.js
    exports.reducer = (state = {count: 0}, action) {
        switch(action.type) {
            case: 'ADD_ACTION': return { ...state, count: state.count + 1 }
            default: return state;
        }
    }
    

  • Step 2: Create and Provide the store to application components. By wrapping the application root component inside the react-redux Provider component, with the global store as its prop, the Provider component will expose store to all the application components. Thie principle of this implementation is: Provider uses store as the prop and passes it down via context, then all the components in the application can read the state, use the methods from store via context. It avoids the to pass down/import the state between components level by level manually.

    //App.js
    import ReactDOM from "react-dom";
    
    import rootReducer from './reducer'
    import ComA from './components/ComA'
    import ComB from './components/ComB'
    
    import { createStore } from 'redux'
    import { Provider } from 'react-redux'
    
    let store = createStore(rootReducer);  // create the store with reducer function
    ReactDOM.render(
        <Provider store={store}>
        	<React.Fragment>
        		<ComA />
        		<ComB />
        	</React.Fragment>
        <Provider />, 
        document.getElementById("root")
    );
      

  • Step 3: Connect the target component. The connect() method bridges a specific component to the store, it maps store state data, action functions as props to the target component and makes store data available to the target component.

    //ComA.js: Component A
    import { connect } from 'react-redux'
    
    // receive dispatch method and use it
    function ComA({sendAction}) {
        return (
        	<button onClick={sendAction()}>Add(+)</button>
        );
    }
    
    // action needs to be dispatched from current component
    const mapDispatchToProps = (dispatch) => {
        return {
            // this dispatch function will become a prop of current component,
            // use this.props.sendAction() to dispatch action 'COMA_ACTION'
            sendAction: () => {
                dispatch({type: 'ADD_ACTION'});
            }
        }
    }
    
    export default const connectedComp = connect(
        null,
        mapDispatchToProps 	// function used to add action dispatcher as current component's props
    )(ComA);  // create a stateful component 
    

    //ComB.js: Component B
    import { connect } from 'react-redux'
    
    function ComB({state}) {
        console.log(state); // receive state
    	return(
    		<h3> { state.count } </h3>
    	)
    }
    
    // use this function to make state be receivable from props
    function mapStateToProps(state, props) {
        return state;     // the returned state will be added as a prop of current component
    }
    
    export default const connectedComp = connect(
        mapStateToProps,  	// function used to add store state as current component's props
    )(ComB);  // create a stateful component
      

  • Data flow of react-redux
    The follwing chart shows how the state transition between ComA and ComB:

Redux vs 'useReducer + useContext' hooks in React

The userReducer hook has similar concepts(state, action, dispatch) to reducer in Redux. It allows us to manage multiple sub-values of state, especially when the new state depends on the previous one, it's a more powerful useState hook, we can literally give it an alias like useMultipleStates for better understand it.

1. userReducer

// ./hooks/reducer.js: reducer function
export const SET_LOAD_STATUS = 'SET_OTHER_OVERALL'
export const SET_COUNT_OVERALL = 'SET_CHINA_OVERALL'

export default function reducer(state, action) {
  switch (action.type) {
    case SET_LOAD_STATUS: return { ...state, loaded: action.loaded };
    case SET_COUNT_OVERALL: return { ...state, overall: action.overall };
    default:
      throw new Error(
        <code>Unsupported action type: ${action.type}</code>
      );
  }
}

// ./hooks/useAppData.js: dispatch actions to reducer.
import { useEffect, useReducer } from "react";
import reducer, { SET_LOAD_STATUS, SET_COUNT_OVERALL,} from "./hooks/reducer";

export default function useAppData(props) {
    const initialState = { loaded: false, overall: []};
    const [state, dispatch] = useReducer(reducer, initialState);

    useEffect(() => {
        // data fetch or process before dispatching actions
        overall = ...;

        // dispatch actions
        dispatch({type: SET_LOAD_STATUS, loaded: true});
        dispatch({type: SET_COUNT_OVERALL, overall: overall})
    }, []);
    
    return state;
}

// component.js: use state data managed by the reducer hook
import updateState from './hooks/useAppData';
import Loader from './components/Loader';
import Overall from './components/Overall';

export default function App() {
 	const { state } = updateState();
    <Loader status={ state.loaded } />
    <Overall data={ state.overall } />
}

2. useContext

Context is designed to share data that can be considered “global” for a tree of React components, such as the current authenticated user, theme, or preferred language.

There are two characteristics of this hook:

  1. When provided context in the original component changes, the component which uses the shared data will be rendered automatically.
  2. There is no need to pass data explicitly to the component props, the shared data is wrapped inside. This provides more flexibility to use shared data to sub-tree components, without restriction of the levels.

// Parent.js: create and provide shared data context
import React, { createContext} from 'react';
const Context = createContext(0);
import Child from './component/Child';

function Parent () {
  return (
    <div>
      <Context.Provider value={count}>
        <Child />
      </Context.Provider>
    </div>
    )
}

function Child () {
  const count = useContext(Context);
  return (
    <div>{ count }</div>
  )
}

3. Combine useReducer and useContext to be a minimum Redux

The userReducer hook provides centralised management of multiple states which can be applied to different sub components via props. However, when the states are used at deeper levels, passing data to props level by level seems tedious and not elegant.

We can get use of the feature of useContext hook at this time to avoid this, as useContext hook allows components on the sub-tree of their ancestor to use data via Context, which is provided by their ancestor.

// ./hooks/useAppData.js
import { useEffect, useReducer, userContext } from "react";

import reducer from "./hooks/reducer";

const Context = createContext({});  // create

export default function useAppData(props) {
    const initialState = { loaded: false, overall: []};
    const [state, dispatch] = useReducer(reducer, initialState);
    
    return (
        // share data via context
       	<Context.Provider value={{state, dispatch: dispatch}}>
            <ChildA/>
            <ChildB/>
        </Context.Provider>
    );
}

function ChildA () {
  const { state } = useContext(Context);  // use context data
  return (
    <div>
    	<div>{ Context.state.count }</div>
    	<button onClick={ () => Context.dispatch({ type: "INCREASE_COUNT", count: state.count + 1 }) }>
    		Increase Count
    	</button>
	</div>
  )
}

function ChildB () {
  return ( <div> <GrandSon /> </div> );
}

function GrandSon () {
  const { state } = useContext(Context);  // use context data
  return (
    <div> Name: {state.grandson} </div>
  )
}

From the above example:

  • we use the reducer function to receive actions and manage state change by userReducer hook;
  • we expose state and dispatch method from the ancestor component to descendant components via the context provided by useContext hook;
  • sub-components consume the shared data and dispatch method via the provided context from ancestor component

We can say that by combination the feature of useReducer and useContext hooks, to some extent, it is feasible to manage the state as using Redux. And the whole process/steps/logic to share data seems similar in the background.

4. Does useReducer and useContext replace Redux?

Redux provides an application level state management solution, while useReducer plus useContext only provide the 'global' state management among sub-tree components of their ancestor component. We can certainly use the root component as the ancestor to make state more higher level and global, or create multiple reducers, create different contexts for different business logic components when the application logic and features become complex; however, these jobs are what Redux actually does, we should not stick to useReducer and useContext in this case, as Redux provides a more complete solutioin with other integrated features for us to manage state easier, safer and more efficiently. So, to summarise:

  • Use useState for basic and simple/small size applications.
  • Use useState + useReducer + useContext for medium size applications.
  • Use useState/useReducer + Redux for complex/large size applications.

Reference

Sunflower

Stay hungry stay foolish

Comments