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

feat: createTrackedSelector #71

Merged
merged 13 commits into from
Dec 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Change Log

## [Unreleased]
### Added
- Export createTrackedSelector, a new building-block function (#71)

## [1.5.1] - 2020-12-19
### Changed
Expand Down
202 changes: 134 additions & 68 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,109 +7,175 @@
[![size](https://img.shields.io/bundlephobia/minzip/react-tracked)](https://bundlephobia.com/result?p=react-tracked)
[![discord](https://img.shields.io/discord/627656437971288081)](https://discord.gg/MrQdmzd)

Simple and fast global state with React Context. Eliminate unnecessary re-renders without hassle.
State usage tracking with Proxies. Optimize re-renders for useState/useReducer, React Redux, Zustand and others.

Documentation site: https://react-tracked.js.org

> If you are looking for a Redux-based library, please visit [reactive-react-redux](https://github.com/dai-shi/reactive-react-redux) which has the same hooks API.

## Introduction

React Context and useContext is often used to avoid prop drilling,
however it's known that there's a performance issue.
When a context value is changed, all components that useContext
will re-render.
React idiomatic usage of the Context API is
to separate concerns into pieces and use multiple contexts.
If each context value is small enough, there shouldn't be
any performance issue.

What if one wants to put a bigger state object into a context
for various reasons?
React Redux is one solution in this field. Redux is designed to
handle one big global state, and React Redux optimizes that use case.

This library tosses a new option. It's based on Context and
typically with useReducer, and provides APIs to solve
the performance issue.
Most notably, it comes with `useTrackedState`, which allows
optimization without hassle. Technically, it uses Proxy underneath,
and it tracks state usage in render so that if only used part of the state
is changed, it will re-render.
Preventing re-renders is one of performance issues in React.
Smaller apps wouldn't usually suffer from such a performance issue,
but once apps have a central global state that would be used in
many components. The performance issue would become a problem.
For example, Redux is usually used for a single global state,
and React-Redux provides a selector interface to solve the performance issue.
Selectors are useful to structure state accessor,
however, using selectors only for performance wouldn't be the best fit.
Selectors for performance require understanding object reference
equality which is non-trival for beginners and
experts would still have difficulties for complex structures.

React Tracked is a library to provide so-called "state usage tracking."
It's a technique to track property access of a state object,
and only triggers re-renders if the accessed property is changed.
Technically, it uses Proxies underneath, and it works not only for
the root level of the object but also for deep nested objects.

Prior to v1.6.0, React Tracked is a library to replace React Context
use cases for global state. React hook useContext triggers re-renders
whenever a small part of state object is changed, and it would cause
performance issues pretty easily. React Tracked provides an API
that is very similar to useContext-style global state.

Since v1.6.0, it provides another building-block API
which is capable to create a "state usage tracking" hooks
from any selector interface hooks.
It can be used with React-Redux useSelector, and any other libraries
that provide useSelector-like hooks.

## Install

```bash
npm install react-tracked
```

## Usage (useTracked)
## Usage

The following shows a minimal example.
Please check out others in the [examples](examples) folder.
There are two main APIs `createContainer` and `createTrackedSelector`.
Both take a hook as an input and return a hook (or a container including a hook).

```javascript
import React, { useReducer } from 'react';
import ReactDOM from 'react-dom';
There could be various use cases. Here are some typical ones.

import { createContainer } from 'react-tracked';
### createContainer / useState

const useValue = ({ reducer, initialState }) => useReducer(reducer, initialState);
const { Provider, useTracked } = createContainer(useValue);
#### Define a `useValue` custom hook

```js
import { useState } from 'react';

const initialState = {
const useValue = () => useState({
count: 0,
text: 'hello',
};
});
```

const reducer = (state, action) => {
switch (action.type) {
case 'increment': return { ...state, count: state.count + 1 };
case 'decrement': return { ...state, count: state.count - 1 };
case 'setText': return { ...state, text: action.text };
default: throw new Error(`unknown action type: ${action.type}`);
}
};
This can be useReducer or any hook that returns a tuple `[state, dispatch]`.

#### Create a container

```js
import { createContainer } from 'react-tracked';

const { Provider, useTracked } = createContainer(useValue);
```

#### useTracked in a component

```jsx
const Counter = () => {
const [state, dispatch] = useTracked();
const [state, setState] = useTracked();
const increment = () => {
setState((prev) => ({
...prev,
count: prev.count + 1,
});
};
return (
<div>
{Math.random()}
<div>
<span>Count: {state.count}</span>
<button type="button" onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button type="button" onClick={() => dispatch({ type: 'decrement' })}>-1</button>
</div>
<span>Count: {state.count}</span>
<button type="button" onClick={increment}>+1</button>
</div>
);
};
```

const TextBox = () => {
const [state, dispatch] = useTracked();
return (
<div>
{Math.random()}
<div>
<span>Text: {state.text}</span>
<input value={state.text} onChange={event => dispatch({ type: 'setText', text: event.target.value })} />
</div>
</div>
);
};
The `useTracked` hook returns a tuple that `useValue` returns,
except that the first is the state wrapped by proxies and
the second part is a wrapped function for a reason.

Thanks to proxies, the property access in render is tracked and
this component will re-render only if `state.count` is changed.

#### Wrap your App with Provider

```jsx
const App = () => (
<Provider reducer={reducer} initialState={initialState}>
<h1>Counter</h1>
<Counter />
<Provider>
<Counter />
<h1>TextBox</h1>
<TextBox />
<TextBox />
</Provider>
);
```

### createTrackedSelector / react-redux

#### Create `useTrackedSelector` from `useSelector`

```js
import { useSelector, useDispatch } from 'react-redux';
import { createTrackedSelector } from 'react-tracked';

ReactDOM.render(<App />, document.getElementById('app'));
const useTrackedSelector = createTrackedSelector(useSelector);
```

#### useTrackedSelector in a component

```jsx
const Counter = () => {
const state = useTrackedSelector();
const dispatch = useDispatch();
return (
<div>
<span>Count: {state.count}</span>
<button type="button" onClick={() => dispatch({ type: 'increment' })}>+1</button>
</div>
);
};
```

### createTrackedSelector / zustand

#### Create useStore

```js
import create from 'zustand';

const useStore = create(() => ({ count: 0 }));
```

#### Create `useTrackedStore` from `useStore`

```js
import { createTrackedSelector } from 'react-tracked';

const useTrackedStore = createTrackedSelector(useStore);
```

#### useStore in a component

```jsx
const Counter = () => {
const state = useTrackedStore();
const increment = () => {
useStore.setState(prev => ({ count: prev.count + 1 }));
};
return (
<div>
<span>Count: {state.count}</span>
<button type="button" onClick={increment}>+1</button>
</div>
);
};
```

## API
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{
"name": "react-tracked",
"description": "Simple and fast global state with React Context. Eliminate unnecessary re-renders without hassle.",
"version": "1.5.1",
"description": "State usage tracking with Proxies. Optimize re-renders for useState/useReducer, React Redux, Zustand and others.",
"version": "1.6.0-beta.2",
"publishConfig": {
"tag": "beta"
},
"author": "Daishi Kato",
"repository": {
"type": "git",
Expand Down
102 changes: 102 additions & 0 deletions src/createTrackedSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
} from 'react';
import {
createDeepProxy,
isDeepChanged,
MODE_ASSUME_UNCHANGED_IF_UNAFFECTED,
MODE_IGNORE_REF_EQUALITY,
MODE_ASSUME_UNCHANGED_IF_UNAFFECTED_IN_DEEP,
} from 'proxy-compare';

import { useAffectedDebugValue } from './utils';

const isSSR = typeof window === 'undefined'
|| /ServerSideRendering/.test(window.navigator && window.navigator.userAgent);

const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect;

const MODE_ALWAYS_ASSUME_CHANGED_IF_UNAFFECTED = 0;
const MODE_ALWAYS_ASSUME_UNCHANGED_IF_UNAFFECTED = (
MODE_ASSUME_UNCHANGED_IF_UNAFFECTED | MODE_ASSUME_UNCHANGED_IF_UNAFFECTED_IN_DEEP
);
const MODE_MUTABLE_ROOT_STATE = MODE_IGNORE_REF_EQUALITY; // only for root
const MODE_IGNORE_ROOT_STATE_USAGE = MODE_ASSUME_UNCHANGED_IF_UNAFFECTED; // only for root

export type Opts = {
/* eslint-disable camelcase */
unstable_ignoreIntermediateObjectUsage?: boolean;
unstable_ignoreStateEquality?: boolean;
unstable_ignoreUntouchedState?: boolean;
/* eslint-enable camelcase */
};

export const createTrackedSelector = <State>(
useSelector: <Selected>(selector: (state: State) => Selected) => Selected,
) => {
const useTrackedSelector = (
opts: Opts = {},
) => {
const [, forceUpdate] = useReducer((c) => c + 1, 0);
const deepChangedMode = (
/* eslint-disable no-nested-ternary, indent, no-multi-spaces */
opts.unstable_ignoreIntermediateObjectUsage ? MODE_ALWAYS_ASSUME_UNCHANGED_IF_UNAFFECTED
: opts.unstable_ignoreStateEquality ? MODE_MUTABLE_ROOT_STATE
: opts.unstable_ignoreUntouchedState ? MODE_IGNORE_ROOT_STATE_USAGE
: /* default */ MODE_ALWAYS_ASSUME_CHANGED_IF_UNAFFECTED
/* eslint-enable no-nested-ternary, indent, no-multi-spaces */
);
const affected = new WeakMap();
const lastAffected = useRef<typeof affected>();
const prevState = useRef<State>();
const lastState = useRef<State>();
useIsomorphicLayoutEffect(() => {
lastAffected.current = affected;
if (prevState.current !== lastState.current
&& isDeepChanged(
prevState.current,
lastState.current,
affected,
new WeakMap(),
deepChangedMode,
)) {
prevState.current = lastState.current;
forceUpdate();
}
});
const selector = useMemo(() => {
const deepChangedCache = new WeakMap();
return (nextState: State) => {
lastState.current = nextState;
if (prevState.current
&& prevState.current !== nextState
&& lastAffected.current
&& !isDeepChanged(
prevState.current,
nextState,
lastAffected.current,
deepChangedCache,
deepChangedMode,
)
) {
// not changed
return prevState.current;
}
prevState.current = nextState;
return nextState;
};
}, [deepChangedMode]);
const state = useSelector(selector);
if (typeof process === 'object' && process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
useAffectedDebugValue(state, affected);
}
const proxyCache = useMemo(() => new WeakMap(), []); // per-hook proxyCache
return createDeepProxy(state, affected, proxyCache);
};
return useTrackedSelector;
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { createContainer } from './createContainer';
export { createTrackedSelector } from './createTrackedSelector';
export { memo } from './memo';
export { getUntrackedObject } from 'proxy-compare';
Loading