In 2019, Let me Replace Redux with React Hooks

1,541 阅读4分钟

React team released react v16.8.0 yesterday. It means React Hooks are available in a stable release! I think you must have searched some articles or libs that describe how to use the new feature to rebuild the state management.

What feature that we want from an awesome hooksful state-management library will be discussed in the following sections, .

项目地址:github.com/byte-fe/rea…

亮点:支持React Hooks, Middleware, TypeScript, immutable, 已经在团队内部使用

Demo:

  1. Parcel + react-model
  2. react-model TodoMVC demo
  3. Next.js + react-model

Requirements

Make use of useState's advantage

useState plays the role of setState in hooks' world. Look at the following snippets.

Class

@connect(...)
class BasicClass extends React.Component {
  componentDidMount() {
    console.log('some mounted actions from BasicClass')
  }
  componentDidUpdate(prevProps, prevState) {
    if (prevProps && prevProps.state) {
      if (prevProps.state.counter === this.props.state.counter - 1) {
        console.log('increment happened')
      }
    }
  }
  componentWillUnmount() {
    console.log('some unmounted actions from BasicClass')
  }
  render() {
    const { state, actions } = this.props
    return (
      <>
        <div>state: {JSON.stringify(state)}</div>
        <div>
          <button style={styles.button} onClick={() => actions.increment(1)}>
            increment
          </button>
        </div>
      </>
    )
  }
}

Hooks

const BasicHook = () => {
  const [counter, setCounter] = useState(0)
  useEffect(() => {
    console.log('some mounted actions from BasicHooks')
    return () => console.log('some unmounted actions from BasicHooks')
  }, [])
  useEffect(() => console.log('increment happened'), [counter])
  return (
    <>
      <div>state: {JSON.stringify(state)}</div>
      <div>
        <button style={styles.button} onClick={() => actions.increment(1)}>
          increment
        </button>
      </div>
    </>
  )
}

Comparing the patterns that use Class lifecycle or hooks utils, the advantages of hooks show that:

  1. Reduce verbosity.
  2. Colocated logic. Allow the reuse of stateful logic in the lifecycle.
  3. More concise, expressive and declarative control over things like state, memorization, and side-effects.
  4. Independent of invocation context. Hooks eliminate the need to consider the value of this within functions that are responsible for mutating local state.
  5. More FP, it make components more composable.

Using the awesome hooks feature is the first requirement of an awesome hooksful state-management library but not the last.

Reduce the developer's concern from shared state, render-props

useState is awesome, but it only manages local state in functional components. So, The problem of global state management is still left to developer to solve. How to make the local state shared may annoy you in the hooks' world.

An awesome hooksful state-management library should reduce the developer‘s concern. Similar to Redux, but cleaner.

Redux

import { Provider } from 'react-redux'
import { App } from './App'
import createStore from './createReduxStore'

const store = createStore()

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

When you want to use the store from provider, connect decorator or Consumer must appear ; )

import * as actionCreators from './actionCreators'
import TodoApp from './TodoApp'

function mapStateToProps(state) {
  return { todos: state.todos }
}

export default connect(
  mapStateToProps,
  actionCreators
)(TodoApp)

Using Hooks, we don't need Provider and render-props again. In fact, most people don’t enjoy manually passing callbacks through every level of a components tree ( more explicit => a lot of “plumbing” ). The works that connect/props doing can be done with the help of global useCallback. ( How to avoid passing callbacks down )

An awesome hooksful state-management library api seems to be:

import { App } from './App'

// No Provider here!
ReactDOM.render(
  <App />,
  document.getElementById('root')
)

When you want to use the store, connect or consumer won't appear : )

import { useStore } from 'models/index.model'

function mapStateToProps(state) {
  return { todos: state.todos }
}

export const TodoApp = () => {
    const [state, actions] = useStore('Todo')
    return <>
    	// some ui make use of state/actions
    </>
}

Custom pipline ( Middleware )

One of the important concepts in Redux is middleware. We can make actions try-catchable, await able with the rich middlewares. An awesome hooksful state-management library should also support it.

Redux way

import { createStore, applyMiddleware } from 'redux'
import todos from './reducers'

function logger({ getState }) {
  return next => action => {
    console.log('will dispatch', action)
    // Call the next dispatch method in the middleware chain.
    const returnValue = next(action)
    console.log('state after dispatch', getState())
    // This will likely be the action itself, unless
    // a middleware further in chain changed it.
    return returnValue
  }
}

const store = createStore(todos, ['Use Redux'], applyMiddleware(logger))

Similar

import { actionMiddlewares, getState, middlewares } from 'xxx'

const logger = async (context, restMiddlewares) => {
    console.log('will dispatch', action)
    // Call the next dispatch method in the middleware chain.
    const returnValue = await context.next(restMiddlewares)
    console.log('state after dispatch', getState())
}

const index = actionMiddlewares.indexOf(middlewares.setState)
actionMiddlewares.splice(index, 0, logger);

Async actions support

The person who use Redux must know redux-thunk. So why not make the actions awaitable directly ?

Example

const initialState = {
  counter: 0,
  response: {}

const Model = {
  actions: {
    increment: async (state, actions, params) => {
      return {
        counter: state.counter + (params || 1)
      }
    },
    get: async () => { // awaitable
      await new Promise((resolve, reject) =>
        setTimeout(() => {
          resolve()
        }, 3000)
      )
      return {
        response: {
          code: 200,
          message: `${new Date().toLocaleString()} open light success`
        }
      }
    }
  },
  state: initialState
}

export default Model

Performance concern ( immutable store )

React's "diffing" algorithm makes the update of component predictable and fast enough for high-performance apps. An awesome hooksful state-management library should create and update the state in the immutable way.

According to that, the partial state the action increment returns should be merged immutably.

Immer may be a good choice for the immutable-support library

Production Ready

React Hooks is prodution ready now. An awesome hooksful state-management library should also been checked on production environment. Server-side rendering (SSR), single page app (SPA) is the most usual case.

SPA is easier, so let us think how SSR works with Hooks.

Next.js extends a lifecycle named getInitialProps on React components. In the Redux world, we use it like the code below:

import withRedux from "next-redux-wrapper";
const Page = ({foo, custom}) => (
  <div>
    <div>Prop from Redux {foo}</div>
    <div>Prop from getInitialProps {custom}</div>
  </div>
);
Page.getInitialProps = ({store, isServer, pathname, query}) => {
  // component will read from store's state when rendered
  store.dispatch({type: 'FOO', payload: 'foo'});
  // pass some custom props to component from here
  return {custom: 'custom'}; 
};

Page = withRedux(makeStore, (state) => ({foo: state.foo}))(Page);

export default Page;

With Hooks, it doesn't make differences.

import { getInitialState, useStore } from '../index.model'

const Page = () => {
  const [state] = useStore('Page')
  const {foo, custom} = state
  return <div>
    <div>Prop from Redux {foo}</div>
    <div>Prop from getInitialProps {custom}</div>
  </div>
};

Page.getInitialProps = async () => {
  return await getInitialState({ modelName: 'Todo' })
}

TypeScript Support

In 2018, TypeScript was used more and more in the Front-end. Most libraries have their index.d.ts or they are written by TypeScript directly. A Type-safe store can reduce the undefined error made by us accidentally a lot.

Without the library's support, TypeScript user need to write type definition here and there:

// StateType, ActionsParamType contain all the type info of store
const Model: ModelType<StateType, ActionsParamType> = {
  actions: {
    increment: (state, _, params) => {
      return {
        counter: state.counter + (params || 1)
      }
    }
  },
  // Provide for SSR
  asyncState: async context => {
    await waitFor(4000)
    return { counter: 500 }
  },
  state: initialState
}

Result

Three months ago, I began to find a library that matches my requirements. Saddly, The answer does not exist. So I try to write a state-management library by myself. Now, It matches the requirement above and works well in the production environment.

Future

To be used in production is only the first step of a library. There are also many works to be done and many issue to be resolved. The following tasks are also remained to complete:

  • react-devtools support
  • Test integration
  • Performance measurement and optimization
  • ...

Finally, PRs, issues, feature requests and contributors are welcomed.😄