A somewhat tricky hook usage

Miklos Bertalan - Feb 4 '19 - - Dev Community

I recently updated a React state management library, hoping to reduce its API from two wrapper functions into a single hook. I did not succeed but I still ended up with something nice. Here is why and how.

The starting point

The library had the following API for dealing with global state

import React from 'react'
import { store, view } from 'react-easy-state'

const count = store({ num: 0 })
const increment = () => count.num++

export default view(() => <button onClick={increment}>{count.num}</button>)

... and local state.

import React, { Component } from 'react'
import { view, store } from 'react-easy-state'

class Counter extends Component {
  counter = store({ num: 0 })
  increment = () => counter.num++

  render() {
    return <button onClick={this.increment}>{this.counter.num}</button>
  }
}

export default view(Counter)

Initially, I hoped to reduce the API to something like below.

import React from 'react'
import useStore from 'react-easy-state'

export default function Counter () {
  const counter = useStore({ num: 0 })
  const increment = () => counter.num++

  return <button onClick={increment}>{counter.num}</button>
}

Notice how the view HOC is gone in this failed experiment.

The issues

  • State management libs are expected to optimize re-renders with shouldComponentUpdate or memo by default. This kind of render bailout can not be done and should not be done with hooks.

  • Some state management libraries need to know details about the components - like what props do they expect or what data do they use from elsewhere. Hooks are not wrapping the components, so they can not provide these kinds of higher level information.

  • A useStore hook is nice for local state in function components but it does not work for global state and local state in class components.

All of the above can be solved with Higher Order Components. Hooks are still needed to enable local state with function components though.

The second iteration

After some tinkering and prototyping, I ditched the idea of reducing the API into a single hook but I tried to not extend it with new functions at least. I succeeded this time through a somewhat unorthodox hook usage.

I overloaded the store function to behave like a hook when it is called inside a function component but keep its original behavior otherwise. As a result, I got this syntax for local state

import React from 'react'
import { view, store } from 'react-easy-state'

export default view(() => {
  const count = store({ num: 0 })
  const increment = () => count.num++

  return <button onClick={increment}>{count.num}</button>
})

... and for global state.

import React from 'react'
import { view, store } from 'react-easy-state'

const count = store({ num: 0 })
const increment = () => count.num++

export default view(() => {
  return <button onClick={increment}>{count.num}</button>
})

Notice that the only difference is the placement of the state stores.

The implementation

This is a simplified version of the view HOC which toggles the isInsideFunctionComponent flag to true while the wrapped function component is rendering. This could not be done with a hook.

import { memo } from 'react'

export let isInsideFunctionComponent = false

export default function view (Comp) {
  return memo(props => {
    isInsideFuntionComponent = true
    try {
      return Comp(props)
    } finally {
      isInsideFunctionComponent = false
    }
  })
}

The below snippet is a simplified version of the store wrapper. It decides if it should behave like a local or global store based on the isInsideFunctionComponent flag.

import { useMemo } from 'react'
import { isInsideFunctionComponent } from './view'

export default function store (obj) {
  if (isInsideFunctionComponent) {
    return useMemo(() => storeImplementation(obj), [])
  }
  return storeImplementation(obj)
}

If store is called inside a function component it returns a memoized local state store, otherwise it returns a global store.

But ...

  • It is not called useStore.

The useX naming is just a convention, which can be broken with a good reason. Ergonomic API design is good enough for me.

  • It is a hook behind an if statement.

If you are familiar with the rules of hooks the if statement likely made you frown, but it is actually there to adhere to the rules not to break them.

Whenever store is called from a function component the isInsideFunctionComponent flag is true and the if block is entered. From the function component's point of view useMemo is not behind an if block, it is called at the correct place every time the component is rendered.

  • It is magic (aka dirty hack).

This is why I love JavaScript. One can save immense efforts with decent language knowledge and an easy mind. Simpler is better if you know what you do.

  • react-easy-state is changing language behavior with ES6 Proxies.
  • I have a lib built around the 'deprecated' with keyword and I think it is great.
  • I like to use sparse arrays and the delete keyword for storing data (in deduped priority queues).
  • The React team started throwing Promises.

These kind of 'hacks' are awesome as long as they do not pose edge cases and hidden pitfalls to their users.


If this article captured your interest please help by sharing it. Also, check out the React Easy State repo if you have some state to manage.

Thanks!

. . . . . . . . . . . .