Skip to content

Commit

Permalink
feat: Sync Recoil changes back to Redux (#7)
Browse files Browse the repository at this point in the history
* Initial support for writing state

* Bidirectional read-write

* Update docs for write support
  • Loading branch information
spautz committed Jul 28, 2020
1 parent 69cfb8b commit 6a01e3e
Show file tree
Hide file tree
Showing 15 changed files with 142 additions and 38 deletions.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ import { SyncReduxToRecoil } from 'redux-to-recoil';
</Provider>;
```

If you want to sync changes from Recoil back to Redux, wrap your reducer with `syncChangesFromRecoil`

```typescript jsx
import { syncChangesFromRecoil } from 'redux-to-recoil';

// This will enable write-from-recoil
const reducer = syncChangesFromRecoil(yourRootReducer);
const store = createStore(reducer /* ... */);
```

```
// Writing from recoil works like normal
const todosAtom = atomFromRedux('.todos'); // wraps state.todos
const [todos, setTodos] = useRecoilState(todosAtom);
setTodos(newTodoList);
```

## Do I need this?

You probably don't need this. Redux and Recoil work fine side-by-side. You can already use values from Redux and Recoil
Expand Down Expand Up @@ -71,6 +90,6 @@ React 16.13. This does not hurt anything, but it is annoying.
- [x] Core functionality: atomFromRedux
- [x] Core functionality: selectorFromReselect
- [ ] Tests
- [ ] Core functionality: middleware for writing back to redux
- [ ] Core functionality: sync changes back to redux
- [x] Demo
- [ ] Initial release
1 change: 1 addition & 0 deletions demos/todo-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"react-scripts": "3.4.1",
"recoil": "^0.0.10",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.8",
"redux-to-recoil": "latest",
"reselect": "^4.0.0"
},
Expand Down
8 changes: 6 additions & 2 deletions demos/todo-list/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { RecoilRoot } from 'recoil';
import { SyncReduxToRecoil } from 'redux-to-recoil';
import { SyncReduxToRecoil, syncChangesFromRecoil } from 'redux-to-recoil';

import rootReducer from './reducers';
import ReduxApp from './App.redux';
import RecoilReadOnlyApp from './App.recoil-readonly';
import RecoilReadWriteApp from './App.recoil-readwrite';

const store = createStore(rootReducer);
const rootReducerWithRecoilSync = syncChangesFromRecoil(rootReducer);

const composeEnhancers = composeWithDevTools();
const store = createStore(rootReducerWithRecoilSync, composeEnhancers);

render(
<React.StrictMode>
Expand Down
5 changes: 5 additions & 0 deletions demos/todo-list/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8880,6 +8880,11 @@ redent@^3.0.0:
indent-string "^4.0.0"
strip-indent "^3.0.0"

redux-devtools-extension@^2.13.8:
version "2.13.8"
resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1"
integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg==

redux-to-recoil@latest:
version "0.0.1"
resolved "https://registry.yarnpkg.com/redux-to-recoil/-/redux-to-recoil-0.0.1.tgz#3f123c701bb4f04e41174693024d98a0df2a02d7"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"types": "tsc --noEmit --p tsconfig.json"
},
"dependencies": {
"@ngard/tiny-get": "^1.2.2"
"immutable-path": "^1.0.1"
},
"devDependencies": {
"@types/jest": "^26.0.7",
Expand Down
14 changes: 9 additions & 5 deletions src/SyncReduxToRecoil.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Store } from 'redux';
import { useSelector, useStore } from 'react-redux';
import { useRecoilState } from 'recoil';

import internalStateAtom from './internalStateAtom';
import { ReduxState } from './types';
import { internalStateAtom } from './internals';
import { ReduxState } from './internals/types';

const selectEntireState = (state: ReduxState) => state;
let store: Store | null = null;
const getStore = (): Store | null => store;

interface SyncReduxToRecoilProps {
export interface SyncReduxToRecoilProps {
enabled?: boolean;
}

const SyncReduxToRecoil: React.FC<SyncReduxToRecoilProps> = (props) => {
const { children, enabled } = props;

// @TODO: Go through middleware to write updates
store = useStore();
const [lastReduxState, setReduxState] = useRecoilState(internalStateAtom);

const currentReduxState = useSelector(selectEntireState);
Expand All @@ -30,3 +33,4 @@ SyncReduxToRecoil.defaultProps = {
};

export default SyncReduxToRecoil;
export { getStore };
54 changes: 35 additions & 19 deletions src/atomFromRedux.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,51 @@
import { get as getPath } from '@ngard/tiny-get';
import { RecoilState, selector } from 'recoil';
import { get as getPath } from 'immutable-path';
import { RecoilState, selectorFamily } from 'recoil';

import internalStateAtom from './internalStateAtom';
import { DefaultReturnType } from './types';
import { DefaultReturnType, internalStateAtom } from './internals';
import {
applyChangesToState,
ChangeEntry,
syncChangesFromRecoilAction,
} from './syncChangesFromRecoil';
import { getStore } from './SyncReduxToRecoil';

const atomSelectorCache = Object.create(null);
const { hasOwnProperty } = Object.prototype;

const atomSelectorFamily = selectorFamily({
key: 'redux-to-recoil:atom',
get: (realPath: string) => ({ get }) => {
const reduxState = get(internalStateAtom);
if (realPath) {
return getPath(reduxState, realPath);
}
return reduxState;
},
set: (realPath: string) => ({ get, set }, newValue: unknown) => {
const reduxState = get(internalStateAtom);
const thisChange: ChangeEntry = [realPath, newValue];
// @TODO: Batching support
const allChanges = [thisChange];
const newState = applyChangesToState(reduxState, allChanges);
set(internalStateAtom, newState);
const reduxStore = getStore();
if (reduxStore) {
reduxStore.dispatch(syncChangesFromRecoilAction(allChanges));
} else {
console.error('Cannot dispatch to Redux store because it is not synced');
}
},
});

// This works similarly to Recoil's atomFamily/selectorFamily, except we de-dupe things by path, and don't keep
// old selectors around for forever.
const atomFromRedux = <ReturnType = DefaultReturnType>(path: string): RecoilState<ReturnType> => {
// The leading dot is just a convention to make things easier to read
const realPath = path.charAt(0) === '.' ? path.substr(1) : path;

if (!hasOwnProperty.call(atomSelectorCache, realPath)) {
console.log('creating selector for path: ', realPath);

// Although named "atomFromRedux", each instance is actually just a selector. They all pull from a single atom.
const selectorForPath = selector<ReturnType>({
key: `redux-to-recoil:atom:${realPath}`,
get: ({ get }) => {
const reduxState = get(internalStateAtom);
if (realPath) {
return getPath(reduxState, realPath);
}
return reduxState;
},
set: ({ get, set }, newValue) => {
console.log('TODO: Bidirectional support', realPath, { get, set }, newValue);
},
});
const selectorForPath = atomSelectorFamily(realPath);

atomSelectorCache[realPath] = selectorForPath;
}
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ export * from './atomFromRedux';
export { default as selectorFromReselect } from './selectorFromReselect';
export * from './selectorFromReselect';

// Danger! This shouldn't be used unless you absolutely know what you're doing
export { default as _internalStateAtom } from './internalStateAtom';
export { default as syncChangesFromRecoil } from './syncChangesFromRecoil';
export * from './syncChangesFromRecoil';

// Danger! Internals shouldn't be used unless you absolutely know what you're doing
export * as _internals from './internals';
4 changes: 4 additions & 0 deletions src/internals/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as internalStateAtom } from './internalStateAtom';
export * from './internalStateAtom';

export * from './types';
File renamed without changes.
File renamed without changes.
3 changes: 2 additions & 1 deletion src/types.ts → src/internals/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// These renames are for readability, and to avoid having to repeatedly disable no-explicit-any when used.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type DefaultReturnType = any;
export type ReduxState = unknown;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ReduxState = any;
4 changes: 2 additions & 2 deletions src/selectorFromReselect.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RecoilValueReadOnly, selector } from 'recoil';

import internalStateAtom from './internalStateAtom';
import { DefaultReturnType, ReduxState } from './types';
import { internalStateAtom } from './internals';
import { DefaultReturnType, ReduxState } from './internals/types';

let count = 0;

Expand Down
47 changes: 47 additions & 0 deletions src/syncChangesFromRecoil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { set as setPath } from 'immutable-path';
import { Reducer } from 'redux';

import { ReduxState } from './internals/types';

const SYNC_CHANGES_FROM_RECOIL = 'SYNC_CHANGES_FROM_RECOIL';

export type ChangeEntry = [string, ReduxState];

export interface SyncFromRecoilAction {
type: typeof SYNC_CHANGES_FROM_RECOIL;
payload: Array<ChangeEntry>;
}

const syncChangesFromRecoilAction = (changes: Array<ChangeEntry>): SyncFromRecoilAction => ({
type: SYNC_CHANGES_FROM_RECOIL,
payload: changes,
});

const applyChangesToState = (state: ReduxState, changes: Array<ChangeEntry>): ReduxState => {
console.log('applyChangesToState()', state, changes);

let newState = state;
for (let i = 0; i < changes.length; i++) {
const [path, value] = changes[i];
newState = setPath(newState, path, value);
}

return newState;
};

const syncChangesFromRecoil = (rootReducer: Reducer): Reducer => {
console.log('syncChangesFromRecoil reducer is registered!');

return (state, action) => {
console.log('syncChangesFromRecoil reducer is running!', action, state);

if (action.type === SYNC_CHANGES_FROM_RECOIL) {
return applyChangesToState(state, action.payload);
} else {
return rootReducer(state, action);
}
};
};

export default syncChangesFromRecoil;
export { applyChangesToState, syncChangesFromRecoilAction };
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1078,11 +1078,6 @@
"@types/yargs" "^15.0.0"
chalk "^4.0.0"

"@ngard/tiny-get@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@ngard/tiny-get/-/tiny-get-1.2.2.tgz#3784ea1fdd852a59a44d50c144f070529422d7a7"
integrity sha512-+B2yx8KGWPxi/z0fqCgA+gNEWGwoVJZ8v98DFHIEVtEfn5BHXV8Wcr9NfracmrZa2dqekyXduPFcw2sL1HUw6Q==

"@rollup/plugin-alias@^3.1.1":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-alias/-/plugin-alias-3.1.1.tgz#bb96cf37fefeb0a953a6566c284855c7d1cd290c"
Expand Down Expand Up @@ -3698,6 +3693,11 @@ ignore@^4.0.6:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==

immutable-path@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/immutable-path/-/immutable-path-1.0.1.tgz#a0033ea1d917348a9ad022e9173c8fa8d015cde5"
integrity sha512-uGYsOvKEYLZR30lnwqSwzk7NwPLTXlVoRwqWZlV9v5aO673NR0l56Gz/2NY6OA5iXWrm6sYKLgqgmAnto1lhjQ==

import-cwd@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
Expand Down

0 comments on commit 6a01e3e

Please sign in to comment.