bostondv / reactive-react-redux

React Redux binding with React Hooks and Proxy

Home Page:https://www.npmjs.com/package/reactive-react-redux

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

reactive-react-redux

Build Status npm version bundle size

React Redux binding with React Hooks and Proxy

If you are looking for a non-Redux library, please visit react-tracked which has the same hooks API.

Introduction

This is a library to bind React and Redux with Hooks API. It has mostly the same API as the official react-redux Hooks API, so it can be used as a drop-in replacement if you are using only basic functionality.

There are two major features in this library that are not in the official react-redux.

1. useTrackedState hook

This library provides another hook useTrackedState which is a simpler API than already simple useSelector. It returns an entire state, but the library takes care of optimization of re-renders. Most likely, useTrackedState performs better than useSelector without perfectly tuned selectors.

Technically, useTrackedState has no stale props issue.

2. state-based object for context value

react-redux v7 uses store-based object for context value, while react-redux v6 used to use state-based object. Using state-based object naively has unable-to-bail-out issue, but this library uses state-based object with undocumented function calculateChangedBits to stop propagation of re-renders. See #29 for details.

How tracking works

A hook useTrackedState returns an entire Redux state object with Proxy, and it keeps track of which properties of the object are used in render. When the state is updated, this hook checks whether used properties are changed. Only if it detects changes in the state, it triggers a component to re-render.

Install

npm install reactive-react-redux

Usage (useTrackedState)

import React from 'react';
import { createStore } from 'redux';
import {
  Provider,
  useDispatch,
  useTrackedState,
} from 'reactive-react-redux';

const initialState = {
  count: 0,
  text: 'hello',
};

const reducer = (state = initialState, 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: return state;
  }
};

const store = createStore(reducer);

const Counter = () => {
  const state = useTrackedState();
  const dispatch = useDispatch();
  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>
    </div>
  );
};

const TextBox = () => {
  const state = useTrackedState();
  const dispatch = useDispatch();
  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>
  );
};

const App = () => (
  <Provider store={store}>
    <h1>Counter</h1>
    <Counter />
    <Counter />
    <h1>TextBox</h1>
    <TextBox />
    <TextBox />
  </Provider>
);

API

This library exports four functions. The first three Provider, useDispatch and useSelector are compatible with react-redux hooks. The last useTrackedState is unique in this library.

Provider

This is a provider component. Typically, it's used closely in the app root component.

const store = createStore(...);
const App = () => (
  <Provider store={store}>
    ...
  </Provider>
);

useDispatch

This is a hook that returns store.dispatch.

const Component = () => {
  const dispatch = useDispatch();
  // ...
};

useSelector

This is a hook that returns a selected value from a state. This is compatible with react-redux's useSelector. It also supports equalityFn.

const Component = () => {
  const selected = useSelector(selector);
  // ...
};

useTrackedState

This is a hook that returns a whole state wraped by proxies. It detects the usage of the state and record it. It will only trigger re-render if the used part is changed. There are some caveats.

const Component = () => {
  const state = useTrackedState();
  // ...
};

trackMemo

This is used to explicitly mark a prop object as used in a memoized component. Otherwise, usage tracking may not work correctly because a memoized component doesn't always render when a parent component renders.

const ChildComponent = React.memo(({ num1, str1, obj1, obj2 }) => {
  trackMemo(obj1);
  trackMemo(obj2);
  // ...
});

getUntrackedObject

There are some cases when we need to get an original object instead of a tracked object. Although it's not a recommended pattern, the library exports a function as an escape hatch.

const Component = () => {
  const state = useTrackedState();
  const dispatch = useUpdate();
  const onClick = () => {
    // this leaks a proxy outside of render
    dispatch({ type: 'FOO', value: state.foo });

    // this works as expected
    dispatch({ type: 'FOO', value: getUntrackedObject(state.foo) });
  };
  // ...
};

Recipes

useTrackedSelector

You can create a selector hook with tracking support.

import { useTrackedState } from 'reactive-react-redux';

export const useTrackedSelector = selector => selector(useTrackedState());

Please refer this issue for more information.

useTracked

You can combine useTrackedState and useDispatch to make a hook that returns a tuple like useReducer.

import { useTrackedState, useDispatch } from 'reactive-react-redux';

export const useTracked = () => {
  const state = useTrackedState();
  const dispatch = useDispatch();
  return useMemo(() => [state, dispatch], [state, dispatch]);
};

Caveats

Proxy and state usage tracking may not work 100% as expected. There are some limitations and workarounds.

Proxied states are referentially equal only in per-hook basis

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

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.

You need to explicitly notify an object as used in a memoized component.

const Child = React.memo(({ foo }) => {
  trackMemo(foo);
  // ...
};

Check out this issue to learn more about the problem and trackMemo.

Proxied state shouldn't be used outside of render

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.

In case you need to pass an object itself, here's a workaround.

dispatch({ type: 'FOO', value: getUntrackedObject(state.foo) });

Examples

The examples folder contains working examples. You can run one of them with

PORT=8080 npm run examples:01_minimal

and open http://localhost:8080 in your web browser.

You can also try them in codesandbox.io: 01 02 03 04 05 06 07 08 09 11 12

Benchmarks

benchmark result

See #32 for details.

Blogs

About

React Redux binding with React Hooks and Proxy

https://www.npmjs.com/package/reactive-react-redux

License:MIT License


Languages

Language:JavaScript 100.0%