Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useTrackedState from reactive-react-redux #1503

Closed
wants to merge 10 commits into from
203 changes: 203 additions & 0 deletions docs/api/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,209 @@ export const CounterComponent = ({ value }) => {
}
```

## `useTrackedState()`

### How does this get used?

`useTrackedState` allows components to read values from the Redux store state.
It is similar to `useSelector`, but uses an internal tracking system
to detect which state values are read in a component,
without needing to define a selector function.

> **Note**: It doesn't mean to replace `useSelector` completely. It gives a new way of connecting Redux store to React.

The usage of `useTrackedState` is like the following.

```jsx
import React from 'react'
import { useTrackedState } from 'react-redux'

export const CounterComponent = () => {
const { counter } = useTrackedState()
return <div>{counter}</div>
}
```

If it needs to use props, it can be done so.

```jsx
import React from 'react'
import { useTrackedState } from 'react-redux'

export const TodoListItem = props => {
const state = useTrackedState()
const todo = state.todos[props.id]
return <div>{todo.text}</div>
}
```

### Why would you want to use it?

#### For beginners

When learning Redux for the first time,
it would be good to learn things step by step.
`useTrackedState` allows developers to directly access the store state.
They can learn selectors later for separation of concerns.

#### For intermediates

`useSelector` requires a selector to produce a stable value for performance.
For example,

```js
const selectUser = state => ({
name: state.user.name,
friends: state.user.friends.map(({ name }) => name),
})
const user = useSelector(selectUser)
```

such a selector needs to be memoized to avoid extra re-renders.

`useTrackedState` doesn't require memoized selectors.

```js
const state = useTrackedState()
const user = selectUser(state)
```

This works fine without extra re-renders.

Even a custom hook can be created for this purpose.

```js
const useTrackedSelector = selector => selector(useTrackedState())
```

This can be used instead of `useSelector` for some cases.

#### For experts

`useTrackedState` doesn't have [the technical issue](#stale-props-and-zombie-children) that `useSelector` has.
This is because `useTrackedState` doesn't run selectors in checkForUpdates.

### What are the differences in behavior compared to useSelector?

#### Capabilities

A selector can create a derived values. For example:

```js
const isYoung = state => state.person.age < 11;
```

This selector computes a boolean value.

```js
const young = useSelector(isYoung);
```

With useSelector, a component only re-renders when the result of `isYoung` is changed.

```js
const young = useTrackedState().person.age < 11;
```

Whereas with useTrackedState, a component re-renders whenever the `age` value is changed.

#### How to debug

Unlike useSelector, useTrackedState's behavior may seem like a magic.
Disclosing the tracked information stored in useTrackedState could mitigate it.
While useSelector shows the selected state with useDebugValue,
useTrackedState shows the tracked state paths with useDebugValue.

By using React Developer Tools, you can investigate the tracked
information in the hook. It is inside `AffectedDebugValue`.
If you experience extra re-renders or missing re-renders,
you can check the tracked state paths which may help finding bugs
in your application code or possible bugs in the library code.

#### Caveats

Proxy-based tracking has limitations.

- Proxied states are referentially equal only in per-hook basis

```js
const state1 = useTrackedState();
const state2 = useTrackedState();
// state1 and state2 is not referentially equal
// even if the underlying redux state is referentially equal.
```

You should use `useTrackedState` only once in a component.

- An object referential change doesn't trigger re-render if an property of the object is accessed in previous render

```js
const state = useTrackedState();
const { foo } = state;
return <Child key={foo.id} foo={foo} />;

const Child = React.memo(({ foo }) => {
// ...
};
// if foo doesn't change, Child won't render, so foo.id is only marked as used.
// it won't trigger Child to re-render even if foo is changed.
```

It's recommended to use primitive values for props with memo'd components.

- Proxied state might behave unexpectedly outside render

Proxies are basically transparent, and it should behave like normal objects.
However, there can be edge cases where it behaves unexpectedly.
For example, if you console.log a proxied value,
it will display a proxy wrapping an object.
Notice, it will be kept tracking outside render,
so any prorerty access will mark as used to trigger re-render on updates.

useTrackedState will unwrap a Proxy before wrapping with a new Proxy,
hence, it will work fine in usual use cases.
There's only one known pitfall: If you wrap proxied state with your own Proxy
outside the control of useTrackedState,
it might lead memory leaks, because useTrackedState
wouldn't know how to unwrap your own Proxy.

To work around such edge cases, use primitive values.

```js
const state = useTrackedState();
const dispatch = useUpdate();
dispatch({ type: 'FOO', value: state.fooObj }); // Instead of using objects,
dispatch({ type: 'FOO', value: state.fooStr }); // Use primitives.
```

#### Performance

useSelector is sometimes more performant because Proxies are overhead.

useTrackedState is sometimes more performant because it doesn't need to invoke a selector when checking for updates.

### What are the limitations in browser support?

Proxies are not supported in old browsers like IE11.

However, one could use [proxy-polyfill](https://github.com/GoogleChrome/proxy-polyfill) with care.

There are some limitations with the polyfill. Most notably, it will fail to track undefined properties.

```js
const state = { count: 0 }

// this works with polyfill.
state.count

// this won't work with polyfill.
state.foo
```

So, if the state shape is defined initially and never changed, it should be fine.

`Object.key()` and `in` operater is not supported. There might be other cases that polyfill doesn't support.

## Custom context

The `<Provider>` component allows you to specify an alternate context via the `context` prop. This is useful if you're building a complex reusable component, and you don't want your store to collide with any Redux store your consumers' applications might use.
Expand Down
128 changes: 128 additions & 0 deletions src/hooks/useTrackedState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/* eslint-env es6 */

import {
useReducer,
useRef,
useMemo,
useContext,
useEffect,
useDebugValue
} from 'react'
import { useReduxContext as useDefaultReduxContext } from './useReduxContext'
import Subscription from '../utils/Subscription'
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect'
import { ReactReduxContext } from '../components/Context'
import { createDeepProxy, isDeepChanged } from '../utils/deepProxy'

// convert "affected" (WeakMap) to serializable value (array of array of string)
const affectedToPathList = (state, affected) => {
const list = []
const walk = (obj, path) => {
const used = affected.get(obj)
if (used) {
used.forEach(key => {
walk(obj[key], path ? [...path, key] : [key])
})
} else if (path) {
list.push(path)
}
}
walk(state)
return list
}

const useAffectedDebugValue = (state, affected) => {
const pathList = useRef(null)
useEffect(() => {
pathList.current = affectedToPathList(state, affected)
})
useDebugValue(pathList.current)
}

function useTrackedStateWithStoreAndSubscription(store, contextSub) {
const [, forceRender] = useReducer(s => s + 1, 0)

const subscription = useMemo(() => new Subscription(store, contextSub), [
store,
contextSub
])

const state = store.getState()
const affected = new WeakMap()
const latestTracked = useRef(null)
useIsomorphicLayoutEffect(() => {
latestTracked.current = {
state,
affected,
cache: new WeakMap()
}
})
useIsomorphicLayoutEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it's not an immediate concern, I'm noticing that we now have 3 different parts of our codebase where we're subscribing to the store in a hook. That may be worth looking at to see if there's some way to abstract it.

function checkForUpdates() {
const nextState = store.getState()
if (
latestTracked.current.state !== nextState &&
isDeepChanged(
latestTracked.current.state,
nextState,
latestTracked.current.affected,
latestTracked.current.cache
)
) {
forceRender()
}
}

subscription.onStateChange = checkForUpdates
subscription.trySubscribe()

checkForUpdates()

return () => subscription.tryUnsubscribe()
}, [store, subscription])

if (process.env.NODE_ENV !== 'production') {
useAffectedDebugValue(state, affected)
}

const proxyCache = useRef(new WeakMap()) // per-hook proxyCache
return createDeepProxy(state, affected, proxyCache.current)
}

/**
* Hook factory, which creates a `useTrackedState` hook bound to a given context.
*
* @param {React.Context} [context=ReactReduxContext] Context passed to your `<Provider>`.
* @returns {Function} A `useTrackedState` hook bound to the specified context.
*/
export function createTrackedStateHook(context = ReactReduxContext) {
const useReduxContext =
context === ReactReduxContext
? useDefaultReduxContext
: () => useContext(context)
return function useTrackedState() {
const { store, subscription: contextSub } = useReduxContext()

return useTrackedStateWithStoreAndSubscription(store, contextSub)
}
}

/**
* A hook to return the redux store's state.
*
* This hook tracks the state usage and only triggers
* re-rerenders if the used part of the state is changed.
*
* @returns {any} the whole state
*
* @example
*
* import React from 'react'
* import { useTrackedState } from 'react-redux'
*
* export const CounterComponent = () => {
* const state = useTrackedState()
* return <div>{state.counter}</div>
* }
*/
export const useTrackedState = /*#__PURE__*/ createTrackedStateHook()
6 changes: 6 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import connect from './connect/connect'
import { useDispatch, createDispatchHook } from './hooks/useDispatch'
import { useSelector, createSelectorHook } from './hooks/useSelector'
import { useStore, createStoreHook } from './hooks/useStore'
import {
useTrackedState,
createTrackedStateHook
} from './hooks/useTrackedState'

import { setBatch } from './utils/batch'
import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'
Expand All @@ -25,5 +29,7 @@ export {
createSelectorHook,
useStore,
createStoreHook,
useTrackedState,
createTrackedStateHook,
shallowEqual
}
Loading