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
145 changes: 145 additions & 0 deletions docs/api/proxy-based-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
timdorr marked this conversation as resolved.
Show resolved Hide resolved
id: proxy-based-tracking
title: Proxy-based Tracking
sidebar_label: Proxy-based Tracking
hide_title: true
---

# Proxy-based Tracking

This document describes about `useTrackedState` hook.
timdorr marked this conversation as resolved.
Show resolved Hide resolved

## How does this get used?

`useTrackedState` is a hook that can be used instead of `useSelector`.
timdorr marked this conversation as resolved.
Show resolved Hide resolved
It doesn't mean to replace `useSelector` completely.
It gives a new way of connecting Redux store to React.

> **Note**: It's not completely new in the sense that there already exists a library for `connect`: [beautiful-react-redux](https://github.com/theKashey/beautiful-react-redux)
timdorr marked this conversation as resolved.
Show resolved Hide resolved

The usage of `useTrackedState` is extremely simple.
timdorr marked this conversation as resolved.
Show resolved Hide resolved

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

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

Using props is intuitive.

```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: Far easier to understand Redux and R-R without the notion of selectors
timdorr marked this conversation as resolved.
Show resolved Hide resolved
>
> For intermediates: Never needs to worry about memoized selectors
>
> For experts: No stale props issue

## 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.

### Caveats

Proxy-based tracking may not work 100% as expected.
timdorr marked this conversation as resolved.
Show resolved Hide resolved

> - 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 shouldn't be used outside of render
>
> ```js
> const state = useTrackedState();
> const dispatch = useUpdate();
> dispatch({ type: 'FOO', value: state.foo }); // This may lead unexpected behavior if state.foo is an object
> dispatch({ type: 'FOO', value: state.fooStr }); // This is OK if state.fooStr is a string
> ```
>
> It's recommended to use primitive values for `dispatch`, `setState` and others.

### 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, and React Native (JavaScript Core).

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 initiall and never changed, it should be fine.
dai-shi marked this conversation as resolved.
Show resolved Hide resolved

`Object.key()` and `in` operater is not supported. There might be other cases that polyfill doesn't support.
92 changes: 92 additions & 0 deletions src/hooks/useTrackedState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* eslint-env es6 */

import { useReducer, useRef, useMemo, useContext } 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'

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])

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