Among react hooks, useEffect is the most commonly used hooks function, but its api experience of manually managing dependency state has been criticized for a long time, and there are countless articles on how to use useEffect properly in the community, but it still doesn’t stop the fact that more newcomers have a hard time using this api properly, which is also jokingly called the react newbie wall. Moreover, react is the only popular web framework that requires manual management of hooks dependencies. Other frameworks such as vue, svelte and solidjs do not require manual dependency management. The recent sudden flurry of discussion about signals in the react community is a reflection of the fact that more people are realizing how bad this dx is, and preact even officially supports signals.

Why are signals suddenly on fire?

Well, my guess is that it’s the adoption of solidjs. vue and svelte don’t require manual dependency management, but they are written very differently from react, they have their own template syntax, vue even requires an extra plugin to use jsx, and the experience isn’t too good, so more people see them as the difference between different frameworks - rather than which hooks api is better. solidjs fully adopts the jsx syntax and community-related toolchain, but the state management is much more developer-friendly, and using useEffect/useMemo/useCallabck no longer requires manual management of dependencies, but is handled automatically in an efficient way.

One signals solves all the problems more elegantly.

  • virtual dom
  • immutable data
  • hooks
  • dependency arrays
  • Compiler optimization and automatic caching

For an example

Dependency passing has dependencies, the functions useEffect/useMemo/useCallback all depend on the deps array parameter. And they can also depend on each other, for example the value of useMemo can be used as a dependency by useCallback. In short, if you use these common react hooks, you have to manage the dependency graph between them manually. If not managed correctly, very subtle errors can occur. react provides an eslint rule to check this, but on the one hand not all projects use eslint, and on the other hand, this eslint rule often seems too strict and must be turned off manually in some cases, such as when using useEffect and wanting to trigger a side effect based on a change in the value of a, but at the same time needing to read the latest b value. but also needs to read the latest b-value, where the eslint rule explodes. On the other hand, reading react’s state immediately after a change doesn’t read the latest, which is not a result of react hooks but a constant problem in react.

Update and Read of State

In the traditional thinking model, you modify the variable and immediately read the latest value.

1
2
3
4
let i = 0
console.log(i) // 0
i += 1
console.log(i) // 1

The react model of thinking uses await new Promise(resolve => setTimeout(0, resolve) to wait for the next loop to read the latest value.

1
2
3
4
5
6
const [i, setI] = useState(0)
console.log(i) // 0
setI(i + 1)
console.log(i) // 0
await new Promise((resolve) => setTimeout(0, resolve))
console.log(i) // 1

The main problem with this approach is that it is lengthy, not intuitive and not particularly reliable.

Or use a temporary variable to save the new value and use the new value later.

1
2
3
4
5
const [i, setI] = useState(0)
console.log(i) // 0
const newI = i + 1
setI(newI)
console.log(newI) // 1

This method is probably the most used in practice, and the main problem is the need to create additional variables

Or with immer, you can use produce to wrap a layer so that the latest values can be read after changes are made in the callback.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import produce from 'immer'

const [i, setI] = useState(0)
console.log(i) // 0
setI(
  produce(i, (draft) => {
    draft += 1
    console.log(draft) // 1
    return draft
  }),
)
console.log(i) // 0

However, this function does not work very well with asynchronous functions. For example, the following code is not possible because when the callback accepted by produce returns a Promise, the result of the produce function will also be a Promise, which is not available for the set function of react. Of course you can add await, but with multiple states you need to merge and split, and all this boilerplate code is annoying.

1
2
3
4
5
6
7
8
setI(
  produce(i, async (draft) => {
    setTimeout(() => {
      draft += 1
    }, 0)
    return draft
  }),
)

Use the code of mobx.

1
2
3
4
const store = useLocalStore(() => ({ value: 0 }))
console.log(store.value) // 0
store.value += 1
console.log(store.value) // 1

The advantage of this model is that you can modify the state directly without using the set function, and you can read the latest value directly without using await to wait for the next loop. Basically, it’s similar to vue’s reactive hooks, generating a mutable object that can then be modified and read, even if it’s deep. In a sense, vue3 hooks is really a simplification of react + mobx, but the template makes many people uncomfortable (and dislike it) compared to jsx.

Dependency Hell

For example, the following code is common in react.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { useState, useEffect } from 'react'

function App() {
  const [text, setText] = useState('')
  const [result, setResult] = useState('')
  useEffect(() => {
    fetch('/api?text=' + text)
      .then((response) => response.text())
      .then((data) => {
        setText(data)
      })
  }, [text])

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <div>{result}</div>
    </div>
  )
}

Using mobx, it can be rewritten as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { observer, useLocalStore } from 'mobx-react-lite'

const App = observer(() => {
  const store = useLocalStore(() => ({
    text: '',
    result: '',
    setText(text: string) {
      this.text = text
      fetch('/api?text=' + this.text)
        .then((response) => response.text())
        .then((data) => {
          this.result = data
        })
    },
  }))

  return (
    <div>
      <input
        value={store.text}
        onChange={(e) => store.setText(e.target.value)}
      />
      <div>{store.result}</div>
    </div>
  )
})

In general, however, it is likely that mobx will only manage state, with the associated function functions at the top level of the component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { observer, useLocalStore, useObserver } from 'mobx-react-lite'

const App = observer(() => {
  const store = useLocalStore(() => ({
    text: '',
    result: '',
  }))

  useObserver(() => {
    fetch('/api?text=' + store.text)
      .then((response) => response.text())
      .then((data) => {
        store.result = data
      })
  })

  return (
    <div>
      <input
        value={store.text}
        onChange={(e) => (store.text = e.target.value)}
      />
      <div>{store.result}</div>
    </div>
  )
})

The same is true for useMemo, which can use mobx’s computed instead, and again, it is automatically optimized.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { observer, useLocalStore, useObserver } from 'mobx-react-lite'

const App = observer(() => {
  const store = useLocalStore(() => ({
    text: '',
    result: '',
    get computedResult() {
      return this.result + this.text
    },
  }))

  useObserver(() => {
    fetch('/api?text=' + store.text)
      .then((response) => response.text())
      .then((data) => {
        store.result = data
      })
  })

  return (
    <div>
      <input
        value={store.text}
        onChange={(e) => (store.text = e.target.value)}
      />
      <div>{store.computedResult}</div>
    </div>
  )
})

Wrapping some tool hooks

Sure, mobx may have some boilerplate code, but that can be solved with some wrapping that looks like vue hooks xd.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { useLocalStore, useObserver } from 'mobx-react-lite'

/**
 * Declare a state, typically for primitive values, such as numbers or strings.
 */
export function useLocalRef<T>(value: T): { value: T } {
  return useLocalStore(() => ({ value }))
}

/**
 * Declare a state, generally for non-primitive values, such as objects or arrays
 */
export function useLocalReactive<T extends Record<string, any>>(value: T): T {
  return useLocalStore(() => value)
}

/**
 * Declare side effects of running according to state changes
 */
export function useLocalWatchEffect(f: () => void, dep?: () => any) {
  useObserver(() => {
    dep?.()
    return f()
  })
}

/**
 * Declare a computed property
 */
export function useLocalComputed<T>(f: () => T): { value: T } {
  const r = useLocalStore(() => ({
    get value() {
      return f()
    },
  }))
  return r
}

Limitations

While mobx is great, it does have some limitations

  • Requires some boilerplate code observer/useLocalStore
  • Child components can modify the incoming state
  • Structured cloning requires the use of toJS to convert proxy proxy objects to normal js objects
  • No direct way to explicitly declare dependency run side effects
  • Can’t completely avoid using some of the methods that come with react hooks, especially when relying on some third-party libraries