57 | );
58 | };
59 |
60 | export default Person;
61 |
--------------------------------------------------------------------------------
/examples/02_typescript/src/Person.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useDispatch, useTrackedState } from './context';
4 |
5 | const Counter: React.FC<{ firstName: string }> = ({ firstName }) => {
6 | const state = useTrackedState();
7 | const dispatch = useDispatch();
8 | return (
9 |
10 | {Math.random()}
11 | {firstName}
12 |
13 | Count: {state.count}
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | const Person = () => {
22 | const state = useTrackedState();
23 | const dispatch = useDispatch();
24 | return (
25 |
59 | );
60 | };
61 |
62 | export default Person;
63 |
--------------------------------------------------------------------------------
/examples/12_async/src/store/reducers.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import {
3 | SubredditPosts,
4 | SelectedSubreddit,
5 | PostsBySubreddit,
6 | State,
7 | Action,
8 | } from './actions';
9 |
10 | const selectedSubreddit = (
11 | state: SelectedSubreddit = 'reactjs',
12 | action: Action,
13 | ): SelectedSubreddit => {
14 | switch (action.type) {
15 | case 'SELECT_SUBREDDIT':
16 | return action.subreddit;
17 | default:
18 | return state;
19 | }
20 | };
21 |
22 | const posts = (state: SubredditPosts = {
23 | isFetching: false,
24 | didInvalidate: false,
25 | items: [],
26 | }, action: Action): SubredditPosts => {
27 | switch (action.type) {
28 | case 'INVALIDATE_SUBREDDIT':
29 | return {
30 | ...state,
31 | didInvalidate: true,
32 | };
33 | case 'REQUEST_POSTS':
34 | return {
35 | ...state,
36 | isFetching: true,
37 | didInvalidate: false,
38 | };
39 | case 'RECEIVE_POSTS':
40 | return {
41 | ...state,
42 | isFetching: false,
43 | didInvalidate: false,
44 | items: action.posts,
45 | lastUpdated: action.receivedAt,
46 | };
47 | default:
48 | return state;
49 | }
50 | };
51 |
52 | const postsBySubreddit = (
53 | state: PostsBySubreddit = {},
54 | action: Action,
55 | ): PostsBySubreddit => {
56 | switch (action.type) {
57 | case 'INVALIDATE_SUBREDDIT':
58 | case 'RECEIVE_POSTS':
59 | case 'REQUEST_POSTS':
60 | return {
61 | ...state,
62 | [action.subreddit]: posts(state[action.subreddit], action),
63 | };
64 | default:
65 | return state;
66 | }
67 | };
68 |
69 | const rootReducer = combineReducers({
70 | postsBySubreddit,
71 | selectedSubreddit,
72 | });
73 |
74 | export default rootReducer;
75 |
--------------------------------------------------------------------------------
/examples/03_deep/src/Person.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useDispatch, useTrackedState } from './context';
4 |
5 | const TextBox: React.SFC<{ text: string }> = ({ text }) => {
6 | // eslint-disable-next-line no-console
7 | console.log('rendering text:', text);
8 | return {text};
9 | };
10 |
11 | const PersonFirstName = () => {
12 | const state = useTrackedState();
13 | const dispatch = useDispatch();
14 | return (
15 |
16 | First Name:
17 |
18 | {
21 | const firstName = event.target.value;
22 | dispatch({ firstName, type: 'setFirstName' });
23 | }}
24 | />
25 |
26 | );
27 | };
28 |
29 | const PersonLastName = () => {
30 | const state = useTrackedState();
31 | const dispatch = useDispatch();
32 | return (
33 |
34 | Last Name:
35 |
36 | {
39 | const lastName = event.target.value;
40 | dispatch({ lastName, type: 'setLastName' });
41 | }}
42 | />
43 |
44 | );
45 | };
46 |
47 | const PersonAge = () => {
48 | const state = useTrackedState();
49 | const dispatch = useDispatch();
50 | return (
51 |
52 | Age:
53 | {
56 | const age = Number(event.target.value) || 0;
57 | dispatch({ age, type: 'setAge' });
58 | }}
59 | />
60 |
61 | );
62 | };
63 |
64 | const Person = () => (
65 | <>
66 |
67 |
68 |
69 | >
70 | );
71 |
72 | export default Person;
73 |
--------------------------------------------------------------------------------
/examples/04_immer/src/Person.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useDispatch, useTrackedState } from './context';
4 |
5 | const TextBox: React.SFC<{ text: string }> = ({ text }) => {
6 | // eslint-disable-next-line no-console
7 | console.log('rendering text:', text);
8 | return {text};
9 | };
10 |
11 | const PersonFirstName = () => {
12 | const state = useTrackedState();
13 | const dispatch = useDispatch();
14 | return (
15 |
16 | First Name:
17 |
18 | {
21 | const firstName = event.target.value;
22 | dispatch({ firstName, type: 'setFirstName' });
23 | }}
24 | />
25 |
26 | );
27 | };
28 |
29 | const PersonLastName = () => {
30 | const state = useTrackedState();
31 | const dispatch = useDispatch();
32 | return (
33 |
34 | Last Name:
35 |
36 | {
39 | const lastName = event.target.value;
40 | dispatch({ lastName, type: 'setLastName' });
41 | }}
42 | />
43 |
44 | );
45 | };
46 |
47 | const PersonAge = () => {
48 | const state = useTrackedState();
49 | const dispatch = useDispatch();
50 | return (
51 |
52 | Age:
53 | {
56 | const age = Number(event.target.value) || 0;
57 | dispatch({ age, type: 'setAge' });
58 | }}
59 | />
60 |
61 | );
62 | };
63 |
64 | const Person = () => (
65 | <>
66 |
67 |
68 |
69 | >
70 | );
71 |
72 | export default Person;
73 |
--------------------------------------------------------------------------------
/examples/01_minimal/src/index.js:
--------------------------------------------------------------------------------
1 | import React, { StrictMode } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { createStore } from 'redux';
4 |
5 | import {
6 | patchStore,
7 | useTrackedState,
8 | } from 'reactive-react-redux';
9 |
10 | const initialState = {
11 | count: 0,
12 | text: 'hello',
13 | };
14 |
15 | const reducer = (state = initialState, action) => {
16 | switch (action.type) {
17 | case 'increment': return { ...state, count: state.count + 1 };
18 | case 'decrement': return { ...state, count: state.count - 1 };
19 | case 'setText': return { ...state, text: action.text };
20 | default: return state;
21 | }
22 | };
23 |
24 | const store = patchStore(createStore(reducer));
25 |
26 | const Counter = () => {
27 | const state = useTrackedState(store);
28 | const { dispatch } = store;
29 | return (
30 |
31 | {Math.random()}
32 |
33 | Count: {state.count}
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | const TextBox = () => {
42 | const state = useTrackedState(store);
43 | const { dispatch } = store;
44 | return (
45 |
46 | {Math.random()}
47 |
48 | Text: {state.text}
49 | dispatch({ type: 'setText', text: event.target.value })} />
50 |
51 |
52 | );
53 | };
54 |
55 | const App = () => (
56 |
57 | <>
58 | Counter
59 |
60 |
61 | TextBox
62 |
63 |
64 | >
65 |
66 | );
67 |
68 | ReactDOM.unstable_createRoot(document.getElementById('app')).render();
69 |
--------------------------------------------------------------------------------
/examples/12_async/src/hooks/useFetchPostsIfNeeded.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 |
3 | import { useDispatch, useStore } from '../context';
4 | import { State, Post } from '../store/actions';
5 |
6 | const shouldFetchPosts = (state: State, subreddit: string) => {
7 | const posts = state.postsBySubreddit[subreddit];
8 | if (!posts) {
9 | return true;
10 | }
11 | if (posts.isFetching) {
12 | return false;
13 | }
14 | return posts.didInvalidate;
15 | };
16 |
17 | const extractPosts = (json: unknown): Post[] | null => {
18 | try {
19 | const posts: Post[] = (json as {
20 | data: {
21 | children: {
22 | data: {
23 | id: string;
24 | title: string;
25 | };
26 | }[];
27 | };
28 | }).data.children.map((child) => child.data);
29 | // type check
30 | if (posts.every((post) => (
31 | typeof post.id === 'string' && typeof post.title === 'string'
32 | ))) {
33 | return posts;
34 | }
35 | return null;
36 | } catch (e) {
37 | return null;
38 | }
39 | };
40 |
41 | const useFetchPostsIfNeeded = () => {
42 | const dispatch = useDispatch();
43 | const store = useStore();
44 | const fetchPostsIfNeeded = useCallback(async (subreddit: string) => {
45 | if (!shouldFetchPosts(store.getState(), subreddit)) {
46 | return;
47 | }
48 | dispatch({
49 | type: 'REQUEST_POSTS',
50 | subreddit,
51 | });
52 | const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`);
53 | const json = await response.json();
54 | const posts = extractPosts(json);
55 | if (!posts) throw new Error('unexpected json format');
56 | dispatch({
57 | type: 'RECEIVE_POSTS',
58 | subreddit,
59 | posts,
60 | receivedAt: Date.now(),
61 | });
62 | }, [dispatch, store]);
63 | return fetchPostsIfNeeded;
64 | };
65 |
66 | export default useFetchPostsIfNeeded;
67 |
--------------------------------------------------------------------------------
/examples/12_async/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect } from 'react';
2 |
3 | import { useSelector } from '../context';
4 | import { State, SelectedSubreddit } from '../store/actions';
5 | import useSelectSubreddit from '../hooks/useSelectSubreddit';
6 | import useFetchPostsIfNeeded from '../hooks/useFetchPostsIfNeeded';
7 | import useInvalidateSubreddit from '../hooks/useInvalidateSubreddit';
8 |
9 | import Picker from './Picker';
10 | import Posts from './Posts';
11 |
12 | const App: React.FC = () => {
13 | const selectedSubreddit = useSelector((state: State) => state.selectedSubreddit);
14 | const postsBySubreddit = useSelector((state: State) => state.postsBySubreddit);
15 | const {
16 | isFetching,
17 | items: posts,
18 | lastUpdated,
19 | } = postsBySubreddit[selectedSubreddit] || {
20 | isFetching: true,
21 | items: [],
22 | lastUpdated: undefined,
23 | };
24 |
25 | const fetchPostsIfNeeded = useFetchPostsIfNeeded();
26 | useEffect(() => {
27 | fetchPostsIfNeeded(selectedSubreddit);
28 | }, [fetchPostsIfNeeded, selectedSubreddit]);
29 |
30 | const selectSubreddit = useSelectSubreddit();
31 | const handleChange = useCallback((nextSubreddit: SelectedSubreddit) => {
32 | selectSubreddit(nextSubreddit);
33 | }, [selectSubreddit]);
34 |
35 | const invalidateSubreddit = useInvalidateSubreddit();
36 | const handleRefreshClick = (e: React.MouseEvent) => {
37 | e.preventDefault();
38 | invalidateSubreddit(selectedSubreddit);
39 | fetchPostsIfNeeded(selectedSubreddit);
40 | };
41 |
42 | const isEmpty = posts.length === 0;
43 | return (
44 |
45 |
50 |
51 | {lastUpdated && (
52 |
53 | Last updated at {new Date(lastUpdated).toLocaleTimeString()}.
54 |
55 | )}
56 | {!isFetching && (
57 |
60 | )}
61 |
62 | {isEmpty && isFetching &&
Loading...
}
63 | {isEmpty && !isFetching &&
Empty.
}
64 | {!isEmpty && (
65 |
68 | )}
69 |
70 | );
71 | };
72 |
73 | export default App;
74 |
--------------------------------------------------------------------------------
/examples/09_thunk/src/Person.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Dispatch } from 'redux';
3 | import { ThunkDispatch } from 'redux-thunk';
4 |
5 | import { State, Action } from './state';
6 | import { useDispatch, useTrackedState } from './context';
7 |
8 | const Counter: React.FC<{ firstName: string }> = ({ firstName }) => {
9 | const state = useTrackedState();
10 | const dispatch = useDispatch();
11 | return (
12 |
13 | {Math.random()}
14 | {firstName}
15 |
16 | Count: {state.count}
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | const Person = () => {
25 | const state = useTrackedState();
26 | const dispatch = useDispatch();
27 | const setRandomFirstName = () => {
28 | const dispatchForThunk = dispatch as ThunkDispatch;
29 | dispatchForThunk(async (d: Dispatch) => {
30 | d({ firstName: 'Loading...', type: 'setFirstName' });
31 | try {
32 | const id = Math.floor(100 * Math.random());
33 | const url = `https://jsonplaceholder.typicode.com/posts/${id}`;
34 | const response = await fetch(url);
35 | const body = await response.json();
36 | d({ firstName: body.title.split(' ')[0], type: 'setFirstName' });
37 | } catch (e) {
38 | d({ firstName: 'ERROR: fetching', type: 'setFirstName' });
39 | }
40 | });
41 | };
42 | return (
43 |
44 | {Math.random()}
45 |
46 |
47 |
48 | First Name:
49 | {
52 | const firstName = event.target.value;
53 | dispatch({ firstName, type: 'setFirstName' });
54 | }}
55 | />
56 |
57 |
58 | Last Name:
59 | {
62 | const lastName = event.target.value;
63 | dispatch({ lastName, type: 'setLastName' });
64 | }}
65 | />
66 |
67 |
68 | Age:
69 | {
72 | const age = Number(event.target.value) || 0;
73 | dispatch({ age, type: 'setAge' });
74 | }}
75 | />
76 |
77 |
78 | );
79 | };
80 |
81 | export default Person;
82 |
--------------------------------------------------------------------------------
/src/useTrackedState.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-ts-comment */
2 |
3 | import {
4 | useCallback,
5 | useEffect,
6 | useLayoutEffect,
7 | useMemo,
8 | useReducer,
9 | useRef,
10 | // @ts-ignore
11 | unstable_useMutableSource as useMutableSource,
12 | } from 'react';
13 | import { Action as ReduxAction, Store } from 'redux';
14 | import { createDeepProxy, isDeepChanged } from 'proxy-compare';
15 |
16 | import { PatchedStore } from './patchStore';
17 | import { useAffectedDebugValue } from './utils';
18 |
19 | const isSSR = typeof window === 'undefined'
20 | || /ServerSideRendering/.test(window.navigator && window.navigator.userAgent);
21 |
22 | const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect;
23 |
24 | const getSnapshot = >(
25 | store: Store,
26 | ) => store.getState();
27 |
28 | /**
29 | * useTrackedState hook
30 | *
31 | * It return the Redux state wrapped by Proxy,
32 | * and the state prperty access is tracked.
33 | * It will only re-render if accessed properties are changed.
34 | *
35 | * @example
36 | * import { useTrackedState } from 'reactive-react-redux';
37 | *
38 | * const Component = () => {
39 | * const state = useTrackedState(store);
40 | * ...
41 | * };
42 | */
43 | export const useTrackedState = >(
44 | patchedStore: PatchedStore,
45 | ) => {
46 | const { mutableSource } = patchedStore;
47 | const [version, forceUpdate] = useReducer((c) => c + 1, 0);
48 | const affected = new WeakMap();
49 | const lastAffected = useRef, unknown>>();
50 | const prevState = useRef();
51 | const lastState = useRef();
52 | useIsomorphicLayoutEffect(() => {
53 | prevState.current = patchedStore.getState();
54 | lastState.current = patchedStore.getState();
55 | }, [patchedStore]);
56 | useIsomorphicLayoutEffect(() => {
57 | lastAffected.current = affected;
58 | if (prevState.current !== lastState.current
59 | && isDeepChanged(
60 | prevState.current,
61 | lastState.current,
62 | affected,
63 | new WeakMap(),
64 | )) {
65 | prevState.current = lastState.current;
66 | forceUpdate();
67 | }
68 | });
69 | const sub = useCallback((store: Store, cb: () => void) => store.subscribe(() => {
70 | const nextState = store.getState();
71 | lastState.current = nextState;
72 | if (prevState.current
73 | && lastAffected.current
74 | && !isDeepChanged(
75 | prevState.current,
76 | nextState,
77 | lastAffected.current,
78 | new WeakMap(),
79 | )
80 | ) {
81 | // not changed
82 | return;
83 | }
84 | prevState.current = nextState;
85 | cb();
86 | }), [version]); // eslint-disable-line react-hooks/exhaustive-deps
87 | const state: State = useMutableSource(mutableSource, getSnapshot, sub);
88 | if (process.env.NODE_ENV !== 'production') {
89 | // eslint-disable-next-line react-hooks/rules-of-hooks
90 | useAffectedDebugValue(state, affected);
91 | }
92 | const proxyCache = useMemo(() => new WeakMap(), []); // per-hook proxyCache
93 | return createDeepProxy(state, affected, proxyCache);
94 | };
95 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reactive-react-redux",
3 | "description": "React Redux binding with React Hooks and Proxy",
4 | "version": "5.0.0-alpha.7",
5 | "publishConfig": {
6 | "tag": "next"
7 | },
8 | "author": "Daishi Kato",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/dai-shi/reactive-react-redux.git"
12 | },
13 | "source": "./src/index.ts",
14 | "main": "./dist/index.umd.js",
15 | "module": "./dist/index.modern.js",
16 | "types": "./dist/src/index.d.ts",
17 | "sideEffects": false,
18 | "files": [
19 | "src",
20 | "dist"
21 | ],
22 | "scripts": {
23 | "compile": "microbundle build -f modern,umd",
24 | "test": "run-s eslint tsc-test jest",
25 | "eslint": "eslint --ext .js,.ts,.tsx --ignore-pattern dist .",
26 | "jest": "jest --preset ts-jest/presets/js-with-ts",
27 | "tsc-test": "tsc --project . --noEmit",
28 | "apidoc": "documentation readme --section API --markdown-toc false --parse-extension ts src/*.ts",
29 | "examples:01_minimal": "DIR=01_minimal EXT=js webpack-dev-server",
30 | "examples:02_typescript": "DIR=02_typescript webpack-dev-server",
31 | "examples:03_deep": "DIR=03_deep webpack-dev-server",
32 | "examples:04_immer": "DIR=04_immer webpack-dev-server",
33 | "examples:05_localstate": "DIR=05_localstate webpack-dev-server",
34 | "examples:06_memoization": "DIR=06_memoization webpack-dev-server",
35 | "examples:07_multistore": "DIR=07_multistore webpack-dev-server",
36 | "examples:08_dynamic": "DIR=08_dynamic webpack-dev-server",
37 | "examples:09_thunk": "DIR=09_thunk webpack-dev-server",
38 | "examples:11_todolist": "DIR=11_todolist EXT=tsx webpack-dev-server",
39 | "examples:12_async": "DIR=12_async EXT=tsx webpack-dev-server",
40 | "examples:13_memo": "DIR=13_memo webpack-dev-server"
41 | },
42 | "keywords": [
43 | "react",
44 | "redux",
45 | "state",
46 | "hooks",
47 | "stateless",
48 | "thisless",
49 | "pure"
50 | ],
51 | "license": "MIT",
52 | "dependencies": {
53 | "proxy-compare": "^1.1.3"
54 | },
55 | "devDependencies": {
56 | "@testing-library/react": "^11.2.2",
57 | "@types/jest": "^26.0.19",
58 | "@types/react": "^17.0.0",
59 | "@types/react-dom": "^17.0.0",
60 | "@types/redux-logger": "^3.0.8",
61 | "@typescript-eslint/eslint-plugin": "^4.11.1",
62 | "@typescript-eslint/parser": "^4.11.1",
63 | "documentation": "^13.1.0",
64 | "eslint": "^7.16.0",
65 | "eslint-config-airbnb": "^18.2.1",
66 | "eslint-plugin-import": "^2.22.1",
67 | "eslint-plugin-jsx-a11y": "^6.4.1",
68 | "eslint-plugin-react": "^7.21.5",
69 | "eslint-plugin-react-hooks": "^4.2.0",
70 | "html-webpack-plugin": "^4.5.0",
71 | "immer": "^8.0.0",
72 | "jest": "^26.6.3",
73 | "microbundle": "^0.13.0",
74 | "npm-run-all": "^4.1.5",
75 | "react": "experimental",
76 | "react-dom": "experimental",
77 | "redux": "^4.0.5",
78 | "redux-thunk": "^2.3.0",
79 | "ts-jest": "^26.4.4",
80 | "ts-loader": "^8.0.12",
81 | "typescript": "^4.1.3",
82 | "webpack": "^4.44.2",
83 | "webpack-cli": "^3.3.12",
84 | "webpack-dev-server": "^3.11.1"
85 | },
86 | "peerDependencies": {
87 | "react": ">=18.0.0",
88 | "redux": ">=4.0.0"
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [Unreleased]
4 | ### Changed
5 | - New API with useMutableSource (#48)
6 |
7 | ## [4.9.0] - 2020-05-10
8 | ### Changed
9 | - Introduce a special `memo` to be used instead of `trackMemo`
10 | - This is technically a breaking change, but released as a minor update.
11 |
12 | ## [4.8.0] - 2020-03-07
13 | ### Changed
14 | - Notify child components updates in callback and effect not in render
15 |
16 | ## [4.7.0] - 2020-03-01
17 | ### Changed
18 | - Add debug value to show tracked paths in useTrackedState
19 | - Unwrap Proxy before wrapping to mitigate possible pitfalls
20 |
21 | ## [4.6.1] - 2020-02-26
22 | ### Changed
23 | - Use improved useIsomorphicLayoutEffect in Provider (#47)
24 |
25 | ## [4.6.0] - 2020-02-24
26 | ### Changed
27 | - A workaround for React render warning (hopefully temporarily)
28 |
29 | ## [4.5.0] - 2020-02-03
30 | ### Changed
31 | - Improve TypeScript typing (#46)
32 |
33 | ## [4.4.1] - 2020-01-24
34 | ### Changed
35 | - Fix typing for createCustomContext (#44)
36 |
37 | ## [4.4.0] - 2019-10-17
38 | ### Added
39 | - A new API getUntrackedObject to revert proxy
40 |
41 | ## [4.3.0] - 2019-10-09
42 | ### Added
43 | - A new API trackMemo to mark objects as used in React.memo
44 |
45 | ## [4.2.1] - 2019-10-05
46 | ### Changed
47 | - Inline useForceUpdate to remove unnecessary deps
48 |
49 | ## [4.2.0] - 2019-07-27
50 | ### Removed
51 | - Remove the experimental useTrackedSelectors hook
52 |
53 | ## [4.1.0] - 2019-07-20
54 | ### Changed
55 | - No useLayoutEffect for invoking listeners (which leads de-opt sync mode)
56 | - Ref: https://github.com/dai-shi/reactive-react-redux/pull/31
57 |
58 | ## [4.0.0] - 2019-06-22
59 | ### Changed
60 | - Direct state context
61 | - Support custom context
62 | - Rename hooks to be somewhat compatible with react-redux hooks
63 |
64 | ## [3.0.0] - 2019-05-18
65 | ### Changed
66 | - New deep proxy instead of proxyequal
67 | - There is no breaking change in API, but it may behave differently from the previous version.
68 |
69 | ## [2.0.1] - 2019-04-15
70 | ### Changed
71 | - Rename src file names to be more consistent
72 |
73 | ## [2.0.0] - 2019-04-13
74 | ### Removed
75 | - Remove experimental useReduxStateMapped
76 | ### Changed
77 | - useLayoutEffect and keep latest state after update (see #20)
78 | - useForceUpdate uses counter instead of boolean (see #20)
79 | - Organize code in multiple files with some improvements
80 | - Update dependencies
81 | ### Added
82 | - New useReduxSelectors (experimental)
83 |
84 | ## [1.8.0] - 2019-04-02
85 | ### Changed
86 | - Improve useReduxStateMapped with proxyequal
87 |
88 | ## [1.7.0] - 2019-04-01
89 | ### Added
90 | - Implement useReduxStateMapped (experimental/unstable/undocumented)
91 | ### Changed
92 | - Rename project
93 | - Old "react-hooks-easy-redux"
94 | - New "reactive-react-redux"
95 |
96 | ## [1.6.0] - 2019-03-25
97 | ### Changed
98 | - Memoize patched store with batchedUpdates
99 |
100 | ## [1.5.0] - 2019-03-25
101 | ### Changed
102 | - No running callback in every commit phase (reverting #5)
103 |
104 | ## [1.4.0] - 2019-03-25
105 | ### Changed
106 | - Avoid recalculating collectValuables for optimization
107 | - Use unstable_batchedUpdates for optimization
108 | - This is not a breaking change as it has a fallback
109 | - Not tested with react-native (help wanted)
110 |
111 | ## [1.3.0] - 2019-03-03
112 | ### Changed
113 | - Better handling stale props issue
114 |
115 | ## [1.2.0] - 2019-02-25
116 | ### Changed
117 | - Cache proxy state for more performance
118 |
119 | ## [1.1.0] - 2019-02-17
120 | ### Changed
121 | - Improve useRef usage for concurrent mode
122 |
123 | ## [1.0.0] - 2019-02-09
124 | ### Changed
125 | - Improve initialization for concurrent mode
126 | - Updated dependencies (React 16.8)
127 |
128 | ## [0.10.0] - 2019-01-29
129 | ### Changed
130 | - Do not use useMemo as a semantic guarantee
131 |
132 | ## [0.9.0] - 2019-01-10
133 | ### Added
134 | - useReduxStateSimple for shallow object comparison
135 |
136 | ## [0.8.0] - 2018-12-24
137 | ### Changed
138 | - No spread guards in proxyequal for better compatibility
139 |
140 | ## [0.7.0] - 2018-12-19
141 | ### Added
142 | - Better warning message for no ReduxProvider
143 | ### Changed
144 | - Refactor to support dynamic updating
145 |
146 | ## [0.6.0] - 2018-12-17
147 | ### Changed
148 | - Support changing store
149 |
150 | ## [0.5.0] - 2018-12-13
151 | ### Changed
152 | - Fix types and examples for the previous change
153 |
154 | ## [0.4.0] - 2018-12-13
155 | ### Changed
156 | - Gave up bailOutHack and use subscriptions
157 |
158 | ## [0.3.0] - 2018-11-20
159 | ### Changed
160 | - bailOutHack with ErrorBoundary
161 |
162 | ## [0.2.0] - 2018-11-17
163 | ### Added
164 | - Use proxyequal for deep change detection
165 |
166 | ## [0.1.0] - 2018-11-15
167 | ### Added
168 | - Initial experimental release
169 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This project is no longer maintained.
2 | [react-tracked](https://react-tracked.js.org) works with react-redux
3 | and covers the use case of reactive-react-redux.
4 | Redux docs officially recommends [proxy-memoize](https://redux.js.org/usage/deriving-data-selectors#proxy-memoize) as a selector library,
5 | and it provides similar developer experience to that of reactive-react-redux.
6 | Both are good options.
7 |
8 | ---
9 |
10 | There are several projects related to this repo.
11 | Here's the index of those.
12 |
13 | - reactive-react-redux v5-alpha (this repo): This has an experimental react-redux binding with useMutableSource. It provides useTrackedState, which tracks the usage of state in render, and it's originally proposed in this repo.
14 | - [react-tracked](https://github.com/dai-shi/react-tracked): This project is to provide useTrackedState with React Context. v1.6 provides createTrackedSelector that will create useTrackedState from useSelector.
15 | - [react-redux #1503](https://github.com/reduxjs/react-redux/pull/1503): A pull request to add useTrackedState to the official react-redux library.
16 | - [proxy-memoize](https://github.com/dai-shi/proxy-memoize): This is another project which is not tied to React, but combined with useSelector, we get a similar functionality like useTrackedState.
17 |
18 | ---
19 |
20 | # reactive-react-redux
21 |
22 | [](https://github.com/dai-shi/reactive-react-redux/actions?query=workflow%3ACI)
23 | [](https://www.npmjs.com/package/reactive-react-redux)
24 | [](https://bundlephobia.com/result?p=reactive-react-redux)
25 | [](https://discord.gg/MrQdmzd)
26 |
27 | React Redux binding with React Hooks and Proxy
28 |
29 | > If you are looking for a non-Redux library, please visit [react-tracked](https://github.com/dai-shi/react-tracked) which has the same hooks API.
30 |
31 | ## Introduction
32 |
33 | This is a library to bind React and Redux with Hooks API.
34 | It has mostly the same API as the official
35 | [react-redux Hooks API](https://react-redux.js.org/api/hooks),
36 | so it can be used as a drop-in replacement
37 | if you are using only basic functionality.
38 |
39 | There are two major features in this library
40 | that are not in the official react-redux.
41 |
42 | ### 1. useTrackedState hook
43 |
44 | This library provides another hook `useTrackedState`
45 | which is a simpler API than already simple `useSelector`.
46 | It returns an entire state, but the library takes care of
47 | optimization of re-renders.
48 | Most likely, `useTrackedState` performs better than
49 | `useSelector` without perfectly tuned selectors.
50 |
51 | Technically, `useTrackedState` has no [stale props](https://react-redux.js.org/api/hooks#stale-props-and-zombie-children) issue.
52 |
53 | ### 2. useMutableSource without Context
54 |
55 | react-redux v7 has APIs around Context.
56 | This library is implemented with useMutableSource,
57 | and it patches the Redux store.
58 | APIs are provided without Context.
59 | It's up to developers to use Context based on them.
60 | Check out `./examples/11_todolist/src/context.ts`.
61 |
62 | There's another difference from react-redux v7.
63 | This library directly use useMutableSource, and requires
64 | useCallback for the selector in useSelector.
65 | [equalityFn](https://react-redux.js.org/api/hooks#equality-comparisons-and-updates) is not supported.
66 |
67 | ## How tracking works
68 |
69 | A hook `useTrackedState` returns an entire Redux state object with Proxy,
70 | and it keeps track of which properties of the object are used
71 | in render. When the state is updated, this hook checks
72 | whether used properties are changed.
73 | Only if it detects changes in the state,
74 | it triggers a component to re-render.
75 |
76 | ## Install
77 |
78 | ```bash
79 | npm install reactive-react-redux
80 | ```
81 |
82 | ## Usage (useTrackedState)
83 |
84 | ```javascript
85 | import React from 'react';
86 | import { createStore } from 'redux';
87 | import {
88 | patchStore,
89 | useTrackedState,
90 | } from 'reactive-react-redux';
91 |
92 | const initialState = {
93 | count: 0,
94 | text: 'hello',
95 | };
96 |
97 | const reducer = (state = initialState, action) => {
98 | switch (action.type) {
99 | case 'increment': return { ...state, count: state.count + 1 };
100 | case 'decrement': return { ...state, count: state.count - 1 };
101 | case 'setText': return { ...state, text: action.text };
102 | default: return state;
103 | }
104 | };
105 |
106 | const store = patchStore(createStore(reducer));
107 |
108 | const Counter = () => {
109 | const state = useTrackedState(store);
110 | const { dispatch } = store;
111 | return (
112 |
113 | {Math.random()}
114 |
115 | Count: {state.count}
116 |
117 |
118 |
119 |
120 | );
121 | };
122 |
123 | const TextBox = () => {
124 | const state = useTrackedState(store);
125 | const { dispatch } = store;
126 | return (
127 |
128 | {Math.random()}
129 |
130 | Text: {state.text}
131 | dispatch({ type: 'setText', text: event.target.value })} />
132 |
133 |
134 | );
135 | };
136 |
137 | const App = () => (
138 | <>
139 | Counter
140 |
141 |
142 | TextBox
143 |
144 |
145 | >
146 | );
147 | ```
148 |
149 | ## API
150 |
151 |
152 |
153 | ### patchStore
154 |
155 | patch Redux store for React
156 |
157 | #### Parameters
158 |
159 | - `store` **Store<State, Action>**
160 |
161 | #### Examples
162 |
163 | ```javascript
164 | import { createStore } from 'redux';
165 | import { patchStore } from 'reactive-react-redux';
166 |
167 | const reducer = ...;
168 | const store = patchStore(createStore(reducer));
169 | ```
170 |
171 | ### useTrackedState
172 |
173 | useTrackedState hook
174 |
175 | It return the Redux state wrapped by Proxy,
176 | and the state prperty access is tracked.
177 | It will only re-render if accessed properties are changed.
178 |
179 | #### Parameters
180 |
181 | - `patchedStore` **PatchedStore<State, Action>**
182 | - `opts` **Opts** (optional, default `{}`)
183 |
184 | #### Examples
185 |
186 | ```javascript
187 | import { useTrackedState } from 'reactive-react-redux';
188 |
189 | const Component = () => {
190 | const state = useTrackedState(store);
191 | ...
192 | };
193 | ```
194 |
195 | ### useSelector
196 |
197 | useSelector hook
198 |
199 | selector has to be stable. Either define it outside render
200 | or use useCallback if selector uses props.
201 |
202 | #### Parameters
203 |
204 | - `patchedStore` **PatchedStore<State, Action>**
205 | - `selector` **function (state: State): Selected**
206 |
207 | #### Examples
208 |
209 | ```javascript
210 | import { useCallback } from 'react';
211 | import { useSelector } from 'reactive-react-redux';
212 |
213 | const Component = ({ count }) => {
214 | const isBigger = useSelector(store, useCallack(state => state.count > count, [count]));
215 | ...
216 | };
217 | ```
218 |
219 | ### memo
220 |
221 | memo
222 |
223 | Using `React.memo` with tracked state is not compatible,
224 | because `React.memo` stops state access, thus no tracking occurs.
225 | This is a special memo to be used instead of `React.memo` with tracking support.
226 |
227 | #### Parameters
228 |
229 | - `Component` **any**
230 | - `areEqual` **any?**
231 |
232 | #### Examples
233 |
234 | ```javascript
235 | import { memo } from 'reactive-react-redux';
236 |
237 | const ChildComponent = memo(({ obj1, obj2 }) => {
238 | // ...
239 | });
240 | ```
241 |
242 | ## Recipes
243 |
244 | ### Context
245 |
246 | You can create Context based APIs like react-redux v7.
247 |
248 | ```typescript
249 | import { createContext, createElement, useContext } from 'react';
250 | import {
251 | PatchedStore,
252 | useSelector as useSelectorOrig,
253 | useTrackedState as useTrackedStateOrig,
254 | } from 'reactive-react-redux';
255 |
256 | export type State = ...;
257 |
258 | export type Action = ...;
259 |
260 | const Context = createContext(new Proxy({}, {
261 | get() { throw new Error('use Provider'); },
262 | }) as PatchedStore);
263 |
264 | export const Provider: React.FC<{ store: PatchedStore }> = ({
265 | store,
266 | children,
267 | }) => createElement(Context.Provider, { value: store }, children);
268 |
269 | export const useDispatch = () => useContext(Context).dispatch;
270 |
271 | export const useSelector = (
272 | selector: (state: State) => Selected,
273 | ) => useSelectorOrig(useContext(Context), selector);
274 |
275 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context));
276 | ```
277 |
278 | ### useTrackedSelector
279 |
280 | You can create a selector hook with tracking support.
281 |
282 | ```javascript
283 | import { useTrackedState } from 'reactive-react-redux';
284 |
285 | export const useTrackedSelector = (patchedStore, selector) => selector(useTrackedState(patchedStore));
286 | ```
287 |
288 | Please refer [this issue](https://github.com/dai-shi/reactive-react-redux/issues/41) for more information.
289 |
290 | ### useTracked
291 |
292 | You can combine useTrackedState and useDispatch to
293 | make a hook that returns a tuple like `useReducer`.
294 |
295 | ```javascript
296 | import { useTrackedState, useDispatch } from 'reactive-react-redux';
297 |
298 | export const useTracked = (patchedStore) => {
299 | const state = useTrackedState(patchedStore);
300 | const dispatch = useDispatch(patchedStore);
301 | return useMemo(() => [state, dispatch], [state, dispatch]);
302 | };
303 | ```
304 |
305 | ## Caveats
306 |
307 | Proxy and state usage tracking may not work 100% as expected.
308 | There are some limitations and workarounds.
309 |
310 | ### Proxied states are referentially equal only in per-hook basis
311 |
312 | ```javascript
313 | const state1 = useTrackedState(patchedStore);
314 | const state2 = useTrackedState(patchedStore);
315 | // state1 and state2 is not referentially equal
316 | // even if the underlying redux state is referentially equal.
317 | ```
318 |
319 | You should use `useTrackedState` only once in a component.
320 |
321 | ### An object referential change doesn't trigger re-render if an property of the object is accessed in previous render
322 |
323 | ```javascript
324 | const state = useTrackedState(patchedStore);
325 | const { foo } = state;
326 | return ;
327 |
328 | const Child = React.memo(({ foo }) => {
329 | // ...
330 | };
331 | // if foo doesn't change, Child won't render, so foo.id is only marked as used.
332 | // it won't trigger Child to re-render even if foo is changed.
333 | ```
334 |
335 | You need to use a special `memo` provided by this library.
336 |
337 | ```javascript
338 | import { memo } from 'reactive-react-redux';
339 |
340 | const Child = memo(({ foo }) => {
341 | // ...
342 | };
343 | ```
344 |
345 | ### Proxied state might behave unexpectedly outside render
346 |
347 | Proxies are basically transparent, and it should behave like normal objects.
348 | However, there can be edge cases where it behaves unexpectedly.
349 | For example, if you console.log a proxied value,
350 | it will display a proxy wrapping an object.
351 | Notice, it will be kept tracking outside render,
352 | so any prorerty access will mark as used to trigger re-render on updates.
353 |
354 | useTrackedState will unwrap a Proxy before wrapping with a new Proxy,
355 | hence, it will work fine in usual use cases.
356 | There's only one known pitfall: If you wrap proxied state with your own Proxy
357 | outside the control of useTrackedState,
358 | it might lead memory leaks, because useTrackedState
359 | wouldn't know how to unwrap your own Proxy.
360 |
361 | To work around such edge cases, the first option is to use primitive values.
362 |
363 | ```javascript
364 | const state = useTrackedState(patchedStore);
365 | const dispatch = useUpdate(patchedStore);
366 | dispatch({ type: 'FOO', value: state.fooObj }); // Instead of using objects,
367 | dispatch({ type: 'FOO', value: state.fooStr }); // Use primitives.
368 | ```
369 |
370 | The second option is to use `getUntrackedObject`.
371 |
372 | ```javascript
373 | import { getUntrackedObject } from 'react-tracked';
374 | dispatch({ type: 'FOO', value: getUntrackedObject(state.fooObj) });
375 | ```
376 |
377 | You could implement a special dispatch function to do this automatically.
378 |
379 | ## Examples
380 |
381 | The [examples](examples) folder contains working examples.
382 | You can run one of them with
383 |
384 | ```bash
385 | PORT=8080 npm run examples:01_minimal
386 | ```
387 |
388 | and open in your web browser.
389 |
390 | You can also try them in codesandbox.io:
391 | [01](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/01_minimal)
392 | [02](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/02_typescript)
393 | [03](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/03_deep)
394 | [04](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/04_immer)
395 | [05](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/05_localstate)
396 | [06](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/06_memoization)
397 | [07](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/07_multistore)
398 | [08](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/08_dynamic)
399 | [09](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/09_thunk)
400 | [11](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/11_todolist)
401 | [12](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/12_async)
402 | [13](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/13_memo)
403 |
404 | ## Benchmarks
405 |
406 |
407 |
408 | See [#32](https://github.com/dai-shi/reactive-react-redux/issues/32) for details.
409 |
410 | ## Blogs
411 |
412 | - [A deadly simple React bindings library for Redux with Hooks API](https://blog.axlight.com/posts/a-deadly-simple-react-bindings-library-for-redux-with-hooks-api/)
413 | - [Developing React custom hooks for Redux without react-redux](https://blog.axlight.com/posts/developing-react-custom-hooks-for-redux-without-react-redux/)
414 | - [Integrating React and Redux, with Hooks and Proxies](https://frontarm.com/daishi-kato/redux-custom-hooks/)
415 | - [New React Redux coding style with hooks without selectors](https://blog.axlight.com/posts/new-react-redux-coding-style-with-hooks-without-selectors/)
416 | - [Benchmark alpha-released hooks API in React Redux with alternatives](https://blog.axlight.com/posts/benchmark-alpha-released-hooks-api-in-react-redux-with-alternatives/)
417 | - [Four patterns for global state with React hooks: Context or Redux](https://blog.axlight.com/posts/four-patterns-for-global-state-with-react-hooks-context-or-redux/)
418 | - [Redux meets hooks for non-redux users: a small concrete example with reactive-react-redux](https://blog.axlight.com/posts/redux-meets-hooks-for-non-redux-users-a-small-concrete-example-with-reactive-react-redux/)
419 | - [Redux-less context-based useSelector hook that has same performance as React-Redux](https://blog.axlight.com/posts/benchmark-react-tracked/)
420 | - [What is state usage tracking? A novel approach to intuitive and performant global state with React hooks and Proxy](https://blog.axlight.com/posts/what-is-state-usage-tracking-a-novel-approach-to-intuitive-and-performant-api-with-react-hooks-and-proxy/)
421 | - [Effortless render optimization with state usage tracking with React hooks](https://blog.axlight.com/posts/effortless-render-optimization-with-state-usage-tracking-with-react-hooks/)
422 | - [How I developed a Concurrent Mode friendly library for React Redux](https://blog.axlight.com/posts/how-i-developed-a-concurrent-mode-friendly-library-for-react-redux/)
423 | - [React hooks-oriented Redux coding pattern without thunks and action creators](https://blog.axlight.com/posts/react-hooks-oriented-redux-coding-pattern-without-thunks-and-action-creators/)
424 |
--------------------------------------------------------------------------------