From c052972f75c2ede72fab700f6dd86280ff754883 Mon Sep 17 00:00:00 2001 From: Alberto Gasparin Date: Thu, 11 May 2023 16:47:28 +1000 Subject: [PATCH 1/4] Shareable container implementation --- .eslintrc.js | 5 +- examples/advanced-shared/components/color.tsx | 37 ++++ .../advanced-shared/components/theming.tsx | 6 + examples/advanced-shared/components/width.tsx | 37 ++++ examples/advanced-shared/index.html | 23 +++ examples/advanced-shared/index.tsx | 56 ++++++ examples/basic-flow/components.js | 40 ---- examples/basic-flow/index.html | 18 -- examples/basic-flow/index.js | 34 ---- examples/basic-ts/index.tsx | 1 - src/__tests__/mocks.js | 6 - src/__tests__/types.flow.js | 45 +++++ src/components/__tests__/container.test.js | 2 + src/components/__tests__/integration.test.js | 56 +++++- src/components/container.js | 175 +++++++++++++++++- src/index.js | 2 - src/index.js.flow | 27 ++- src/store/__tests__/create-state.test.js | 22 +-- src/store/__tests__/registry.test.js | 7 + src/store/create-state.js | 2 +- src/store/create.js | 10 +- src/store/registry.js | 5 + types/index.d.ts | 63 ++++++- types/test.tsx | 63 ++++++- 24 files changed, 604 insertions(+), 138 deletions(-) create mode 100644 examples/advanced-shared/components/color.tsx create mode 100644 examples/advanced-shared/components/theming.tsx create mode 100644 examples/advanced-shared/components/width.tsx create mode 100644 examples/advanced-shared/index.html create mode 100644 examples/advanced-shared/index.tsx delete mode 100644 examples/basic-flow/components.js delete mode 100644 examples/basic-flow/index.html delete mode 100644 examples/basic-flow/index.js diff --git a/.eslintrc.js b/.eslintrc.js index f19438b..3a1cb6e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,7 +8,6 @@ module.exports = { 'eslint:recommended', 'plugin:react/recommended', 'plugin:prettier/recommended', - 'plugin:flowtype/recommended', ], parserOptions: { ecmaFeatures: { @@ -26,7 +25,7 @@ module.exports = { // fix for eslint-plugin-flowtype/384 not supporting wildcard _: 'readonly', }, - plugins: ['react', 'react-hooks', 'import', 'flowtype'], + plugins: ['react', 'react-hooks', 'import'], rules: { 'no-shadow': ['error'], indent: ['off'], @@ -45,7 +44,7 @@ module.exports = { { // Flow specific rules files: ['src/index.js.flow', '*/*flow.js', 'examples/*-flow/*/*.js'], - // extends: ['plugin:flowtype/recommended'], + extends: ['plugin:flowtype/recommended'], plugins: ['flowtype'], rules: { 'flowtype/generic-spacing': ['off'], diff --git a/examples/advanced-shared/components/color.tsx b/examples/advanced-shared/components/color.tsx new file mode 100644 index 0000000..e8a1352 --- /dev/null +++ b/examples/advanced-shared/components/color.tsx @@ -0,0 +1,37 @@ +import { + createStore, + createHook, + type StoreActionApi, +} from 'react-sweet-state'; +import { ThemingContainer } from './theming'; + +type State = { + color: string; +}; + +const initialState: State = { + color: 'white', +}; + +const actions = { + set: + (color: string) => + ({ setState }: StoreActionApi) => { + setState({ color }); + }, +}; + +const Store = createStore({ + initialState, + actions, + containedBy: ThemingContainer, + handlers: { + onInit: + () => + ({ setState }, { initialData }) => { + if (initialData) setState({ color: initialData.color }); + }, + }, +}); + +export const useColor = createHook(Store); diff --git a/examples/advanced-shared/components/theming.tsx b/examples/advanced-shared/components/theming.tsx new file mode 100644 index 0000000..85fc4d1 --- /dev/null +++ b/examples/advanced-shared/components/theming.tsx @@ -0,0 +1,6 @@ +import { createContainer } from 'react-sweet-state'; + +export type ThemingContainerProps = { + initialData?: { width: number; color: string }; +}; +export const ThemingContainer = createContainer(); diff --git a/examples/advanced-shared/components/width.tsx b/examples/advanced-shared/components/width.tsx new file mode 100644 index 0000000..604bcd7 --- /dev/null +++ b/examples/advanced-shared/components/width.tsx @@ -0,0 +1,37 @@ +import { + createStore, + createHook, + type StoreActionApi, +} from 'react-sweet-state'; +import { ThemingContainer } from './theming'; + +type State = { + width: number; +}; + +const initialState: State = { + width: 200, +}; + +const actions = { + set: + (width: number) => + ({ setState }: StoreActionApi) => { + setState({ width }); + }, +}; + +const Store = createStore({ + initialState, + actions, + containedBy: ThemingContainer, + handlers: { + onInit: + () => + ({ setState }, { initialData }) => { + if (initialData) setState({ width: initialData.width }); + }, + }, +}); + +export const useWidth = createHook(Store); diff --git a/examples/advanced-shared/index.html b/examples/advanced-shared/index.html new file mode 100644 index 0000000..bed69b2 --- /dev/null +++ b/examples/advanced-shared/index.html @@ -0,0 +1,23 @@ + + + + Advanced shared scoped example + + + + +
+ + + diff --git a/examples/advanced-shared/index.tsx b/examples/advanced-shared/index.tsx new file mode 100644 index 0000000..9ef5ca2 --- /dev/null +++ b/examples/advanced-shared/index.tsx @@ -0,0 +1,56 @@ +import React, { StrictMode } from 'react'; +import ReactDOM from 'react-dom/client'; + +import { useColor } from './components/color'; +import { useWidth } from './components/width'; +import { ThemingContainer } from './components/theming'; + +const colors = ['white', 'aliceblue', 'beige', 'gainsboro', 'honeydew']; +const widths = [200, 220, 240, 260, 280]; +const rand = () => Math.floor(Math.random() * colors.length); +const initialData = { color: colors[rand()], width: widths[rand()] }; + +/** + * Components + */ +const ThemeHook = ({ title }: { title: string }) => { + const [{ color }, { set: setColor }] = useColor(); + const [{ width }, { set: setWidth }] = useWidth(); + + return ( +
+

Component {title}

+

Color: {color}

+

Width: {width}

+ + +
+ ); +}; + +/** + * Main App + */ +const App = () => ( +
+

Advanced dynamic scoped example

+
+ + + + + + + + + +
+
+); + +const root = ReactDOM.createRoot(document.getElementById('root')!); +root.render( + + + +); diff --git a/examples/basic-flow/components.js b/examples/basic-flow/components.js deleted file mode 100644 index 82316bc..0000000 --- a/examples/basic-flow/components.js +++ /dev/null @@ -1,40 +0,0 @@ -// @flow - -import { - createStore, - createSubscriber, - createHook, - type Action, - type SubscriberComponent, - type HookFunction, -} from 'react-sweet-state'; - -type State = {| - count: number, -|}; - -type Actions = typeof actions; - -const initialState: State = { - count: 0, -}; - -const actions = { - increment: - (): Action => - ({ setState, getState }) => { - setState({ - count: getState().count + 1, - }); - }, -}; - -const Store = createStore({ - initialState, - actions, -}); - -export const CounterSubscriber: SubscriberComponent = - createSubscriber(Store); - -export const useCounter: HookFunction = createHook(Store); diff --git a/examples/basic-flow/index.html b/examples/basic-flow/index.html deleted file mode 100644 index 7738059..0000000 --- a/examples/basic-flow/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - Basic example with Flow - - - - -
- - - - diff --git a/examples/basic-flow/index.js b/examples/basic-flow/index.js deleted file mode 100644 index b110432..0000000 --- a/examples/basic-flow/index.js +++ /dev/null @@ -1,34 +0,0 @@ -// @flow -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { useCounter } from './components'; - -/** - * Components - */ -const CounterHook = () => { - const [{ count }, { increment }] = useCounter(); - - return ( -
-

With Hooks

-

{count}

- -
- ); -}; - -/** - * Main App - */ -const App = () => ( -
-

Simple counter example

-
- -
-
-); - -ReactDOM.render(, document.getElementById('root')); diff --git a/examples/basic-ts/index.tsx b/examples/basic-ts/index.tsx index 8788f10..f198ede 100644 --- a/examples/basic-ts/index.tsx +++ b/examples/basic-ts/index.tsx @@ -1,4 +1,3 @@ -// @flow import React, { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; diff --git a/src/__tests__/mocks.js b/src/__tests__/mocks.js index 51ce8fa..202e0cc 100644 --- a/src/__tests__/mocks.js +++ b/src/__tests__/mocks.js @@ -21,9 +21,3 @@ export const storeStateMock = { listeners: () => [], mutator: () => {}, }; - -export const registryMock = { - configure: jest.fn(), - getStoreState: jest.fn(), - deleteStoreState: jest.fn(), -}; diff --git a/src/__tests__/types.flow.js b/src/__tests__/types.flow.js index cf40c09..d9dc669 100644 --- a/src/__tests__/types.flow.js +++ b/src/__tests__/types.flow.js @@ -426,3 +426,48 @@ Test = ( bla ); + +/** + * Shared Container types tests + */ + +type SharedContainerProps = {| + initValue?: number, +|}; +const TypeSharedContainer = createContainer(); + +Test = ( + // $FlowExpectedError[incompatible-type] + {({ count }) => count} +); + +Test = ( + // $FlowExpectedError[prop-missing] + bla +); + +// Correct +Test = bla; +Test = bla; +Test = bla; +Test = bla; + +createStore({ + initialState: { count: 0 }, + actions: {}, + containedBy: TypeSharedContainer, + handlers: { + onInit: + () => + ({ setState }, { initValue }) => { + if (initValue) { + // $FlowExpectedError[prop-missing] + initValue.split; + setState({ count: initValue }); + } + }, + onUpdate: () => () => undefined, + onDestroy: () => () => undefined, + onContainerUpdate: () => () => undefined, + }, +}); diff --git a/src/components/__tests__/container.test.js b/src/components/__tests__/container.test.js index e5f4a47..3b3fc08 100644 --- a/src/components/__tests__/container.test.js +++ b/src/components/__tests__/container.test.js @@ -12,6 +12,7 @@ import { createSubscriber } from '../subscriber'; const mockLocalRegistry = { configure: jest.fn(), getStore: jest.fn(), + hasStore: jest.fn(), deleteStore: jest.fn(), }; @@ -21,6 +22,7 @@ jest.mock('../../store/registry', () => ({ defaultRegistry: { configure: jest.fn(), getStore: jest.fn(), + hasStore: jest.fn(), deleteStore: jest.fn(), }, })); diff --git a/src/components/__tests__/integration.test.js b/src/components/__tests__/integration.test.js index 0d04ca7..8ebcbe7 100644 --- a/src/components/__tests__/integration.test.js +++ b/src/components/__tests__/integration.test.js @@ -32,6 +32,7 @@ const actions = { }, }; const Store = createStore({ + name: 'store', initialState: { todos: [], loading: false }, actions, }); @@ -180,8 +181,8 @@ describe('Integration', () => { const state3 = { loading: false, todos: ['todoB'] }; const call3 = 3; - // its 3+1 because on scope change we force notify to make sure memo components update too, - // causing ones that have naturally re-rendered already to re-render once more :( + // its 3+1 because on scope change we do NOT use context and force notify + // causing ones that have naturally re-rendered already to re-render once more. expect(children1.mock.calls[call3 + 1]).toEqual([state3, expectActions]); expect(children2.mock.calls[call3]).toEqual([state3, expectActions]); }); @@ -405,4 +406,55 @@ describe('Integration', () => { expect(selector).toHaveBeenCalledTimes(2); }); + + it.only('should capture all contained stores', async () => { + const onInit = jest.fn().mockReturnValue(() => {}); + const onUpdate = jest.fn().mockReturnValue(() => {}); + const onDestroy = jest.fn().mockReturnValue(() => {}); + const onContainerUpdate = jest.fn().mockReturnValue(() => {}); + const SharedContainer = createContainer(); + const Store1 = createStore({ + name: 'store', + initialState: { todos: [], loading: false }, + actions, + containedBy: SharedContainer, + handlers: { onInit, onUpdate, onDestroy, onContainerUpdate }, + }); + const Store2 = createStore({ + name: 'two', + initialState: {}, + actions, + containedBy: SharedContainer, + handlers: { onInit, onUpdate, onDestroy, onContainerUpdate }, + }); + const Subscriber = createSubscriber(Store1); + const Subscriber2 = createSubscriber(Store2); + + let acts; + + const App = ({ value }) => ( + + {(_, a) => ((acts = a), null)} + {() => null} + + ); + + const { rerender, unmount } = render(); + + expect(onInit).toHaveBeenCalledTimes(2); + expect(defaultRegistry.stores.size).toEqual(0); + + act(() => acts.add('todo2')); + + expect(onUpdate).toHaveBeenCalledTimes(1); + + rerender(); + + expect(onContainerUpdate).toHaveBeenCalledTimes(2); + + unmount(); + await actTick(); + + expect(onDestroy).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/components/container.js b/src/components/container.js index 7d57999..5defcc4 100644 --- a/src/components/container.js +++ b/src/components/container.js @@ -1,4 +1,11 @@ -import React, { Component } from 'react'; +import React, { + Component, + useCallback, + useContext, + useMemo, + useRef, + useEffect, +} from 'react'; import PropTypes from 'prop-types'; import { Context } from '../context'; @@ -179,13 +186,167 @@ export default class Container extends Component { } export function createContainer( - Store, + StoreOrOptions = {}, { onInit = noop, onUpdate = noop, onCleanup = noop, displayName = '' } = {} ) { - return class extends Container { - static storeType = Store; - static displayName = - displayName || `Container(${Store.key.split('__')[0]})`; - static hooks = { onInit, onUpdate, onCleanup }; + if ('key' in StoreOrOptions) { + const Store = StoreOrOptions; + const dn = displayName || `Container(${Store.key.split('__')[0]})`; + + return class extends Container { + static storeType = Store; + static displayName = dn; + static hooks = { onInit, onUpdate, onCleanup }; + }; + + // eslint-disable-next-line no-unreachable + return createFunctionContainer({ + displayName: dn, + // compat fields + override: { + Store, + handlers: { + ...(onInit !== noop && { onInit: () => onInit() }), + ...(onCleanup !== noop && { onDestroy: () => onCleanup() }), + ...(onUpdate !== noop && { onContainerUpdate: () => onUpdate() }), + }, + }, + }); + } + + return createFunctionContainer(StoreOrOptions); +} + +function useRegistry(scope, isGlobal, { globalRegistry }) { + return useMemo(() => { + const isLocal = !scope && !isGlobal; + return isLocal ? new StoreRegistry('__local__') : globalRegistry; + }, [scope, isGlobal, globalRegistry]); +} + +function useContainedStore(scope, registry, props, override) { + // Store contained scopes in a map, but throwing it away on scope change + // eslint-disable-next-line react-hooks/exhaustive-deps + const containedStores = useMemo(() => new Map(), [scope]); + + // Store props in a ref to avoid re-binding actions when they change + // If devs want to update consumers on prop change, they can put them in store + const containerProps = useRef(); + containerProps.current = props; + + const getContainedStore = useCallback( + (Store) => { + let containedStore = containedStores.get(Store); + // first time it gets called we add store to contained map bound + // so we can provide props to actions (only triggered by children) + if (!containedStore) { + const isExisting = registry.hasStore(Store, scope); + const { storeState } = registry.getStore(Store, scope); + const getProps = () => containerProps.current; + const actions = bindActions(Store.actions, storeState, getProps); + const handlers = bindActions( + { ...Store.handlers, ...override?.handlers }, + storeState, + getProps, + actions + ); + containedStore = { + storeState, + actions, + handlers, + unsubscribe: storeState.subscribe(() => handlers.onUpdate?.()), + }; + containedStores.set(Store, containedStore); + // signal store is contained and ready now, so by the time + // consumers subscribe we already have updated the store (if needed) + if (!isExisting) handlers.onInit?.(); + } + return containedStore; + }, + [containedStores, registry, scope, override] + ); + return [containedStores, getContainedStore]; +} + +function useApi(check, getContainedStore, { globalRegistry, getStore }) { + const getStoreRef = useRef(); + getStoreRef.current = (Store) => + check(Store) ? getContainedStore(Store) : getStore(Store); + + // This api is "frozen", as changing it will trigger re-render across all consumers + // so we link getStore dynamically and manually call notify() on scope change + return useMemo( + () => ({ globalRegistry, getStore: (s) => getStoreRef.current(s) }), + [globalRegistry] + ); +} + +function createFunctionContainer({ displayName, override } = {}) { + const check = (store) => + override + ? store === override.Store + : store.containedBy === FunctionContainer; + + function FunctionContainer({ children, scope, isGlobal, ...restProps }) { + const ctx = useContext(Context); + const registry = useRegistry(scope, isGlobal, ctx); + const [containedStores, getContainedStore] = useContainedStore( + scope, + registry, + restProps, + override + ); + const api = useApi(check, getContainedStore, ctx); + + // This listens for custom props change, and so we trigger container update actions + // before the re-render gets to consumers, hence why memo and not effect + useMemo(() => { + containedStores.forEach(({ handlers }) => { + handlers.onContainerUpdate?.(); + }); + // Deps are dynamic because we want to notify on any custom prop change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, Object.values(restProps).concat(containedStores)); + + // This listens for scope change or component unmount, to notify all consumers + // so all work is done on cleanup + useEffect(() => { + const cachedScope = scope; + return () => { + containedStores.forEach( + ({ storeState, handlers, unsubscribe }, Store) => { + // Detatch container from subscription + unsubscribe(); + // Trigger a forced update on all subscribers as we opted out from context + // Some might have already re-rendered naturally, but we "force update" all anyway. + // This is sub-optimal as if there are other containers with the same + // old scope id we will re-render those too, but still better than context + storeState.notify(); + // Schedule check if instance has still subscribers, if not delete + Promise.resolve().then(() => { + if ( + !storeState.listeners().length && + // ensure registry has not already created a new store with same scope + storeState === registry.getStore(Store, cachedScope).storeState + ) { + handlers.onDestroy?.(); + registry.deleteStore(Store, cachedScope); + } + }); + } + ); + }; + }, [registry, scope, containedStores]); + + return {children}; + } + + FunctionContainer.displayName = displayName || `Container`; + FunctionContainer.propTypes = { + children: PropTypes.node, + scope: PropTypes.string, + isGlobal: PropTypes.bool, }; + + return FunctionContainer; } diff --git a/src/index.js b/src/index.js index 72e7558..71a7086 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,3 @@ -// @flow - export { createContainer } from './components/container'; export { createSubscriber } from './components/subscriber'; export { diff --git a/src/index.js.flow b/src/index.js.flow index cb0af44..2733b7c 100644 --- a/src/index.js.flow +++ b/src/index.js.flow @@ -27,6 +27,16 @@ export type Store = {| key: string, initialState: ST, actions: AC, + containedBy?: ContainerComponent, + handlers?: { + onInit?: () => (api: StoreActionApi, containerProps: any) => any, + onUpdate?: () => (api: StoreActionApi, containerProps: any) => any, + onDestroy?: () => (api: StoreActionApi, containerProps: any) => any, + onContainerUpdate?: () => ( + api: StoreActionApi, + containerProps: any + ) => any, + }, |}; export type StoreState = {| @@ -82,6 +92,7 @@ declare export class Registry { store: Store, scopeId?: string ) => StoreInstance; + hasStore: (store: Store, scopeId?: string) => boolean; deleteStore: (store: Store, scopeId?: string) => void; } @@ -125,16 +136,30 @@ export type HookStateFunction = (props?: PR) => ST; * createStore */ -declare export function createStore({| +declare export function createStore({| initialState: ST, actions: AC, name?: string, + containedBy?: ContainerComponent, + handlers?: { + onInit?: () => (api: StoreActionApi, containerProps: CPR) => any, + onUpdate?: () => (api: StoreActionApi, containerProps: CPR) => any, + onDestroy?: () => (api: StoreActionApi, containerProps: CPR) => any, + onContainerUpdate?: () => ( + api: StoreActionApi, + containerProps: CPR + ) => any, + }, |}): Store; /** * createContainer */ +declare export function createContainer(config?: {| + displayName?: string, +|}): ContainerComponent; + declare export function createContainer( store: Store, options?: {| diff --git a/src/store/__tests__/create-state.test.js b/src/store/__tests__/create-state.test.js index 572a674..323b02b 100644 --- a/src/store/__tests__/create-state.test.js +++ b/src/store/__tests__/create-state.test.js @@ -3,13 +3,13 @@ import { storeStateMock } from '../../__tests__/mocks'; import defaults from '../../defaults'; import supports from '../../utils/supported-features'; -import createStore from '../create-state'; +import createStoreState from '../create-state'; const initialState = { count: 0 }; -describe('createStore', () => { +describe('createStoreState', () => { it('should return a store object', () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); expect(store).toEqual({ key: storeStateMock.key, getState: expect.any(Function), @@ -24,21 +24,21 @@ describe('createStore', () => { describe('getState()', () => { it('should return current state', () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); expect(store.getState()).toBe(initialState); }); }); describe('setState()', () => { it('should replace current state', () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); const newState = { count: 1 }; store.setState(newState); expect(store.getState()).toBe(newState); }); it('should notify listeners when multiple calls', () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); const listener = jest.fn(); store.subscribe(listener); store.setState({ count: 1 }); @@ -53,7 +53,7 @@ describe('createStore', () => { .mockReturnValue(true); defaults.batchUpdates = true; - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); const listener = jest.fn(); store.subscribe(listener); @@ -71,7 +71,7 @@ describe('createStore', () => { describe('resetState()', () => { it('should replace current state with initial state', async () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); store.setState({ count: 1 }); const listener = jest.fn(); store.subscribe(listener); @@ -82,7 +82,7 @@ describe('createStore', () => { describe('notify()', () => { it('should notify listeners', () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); const listener = jest.fn(); store.subscribe(listener); store.notify(); @@ -92,7 +92,7 @@ describe('createStore', () => { describe('unsubscribe()', () => { it('should remove listener', () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); const newState = { count: 1 }; const listener = jest.fn(); const unsubscribe = store.subscribe(listener); @@ -104,7 +104,7 @@ describe('createStore', () => { describe('mutator()', () => { it('should modify state', () => { - const store = createStore(storeStateMock.key, initialState); + const store = createStoreState(storeStateMock.key, initialState); store.mutator({ count: 1, }); diff --git a/src/store/__tests__/registry.test.js b/src/store/__tests__/registry.test.js index bd78ea4..fe9d802 100644 --- a/src/store/__tests__/registry.test.js +++ b/src/store/__tests__/registry.test.js @@ -20,6 +20,13 @@ describe('StoreRegistry', () => { expect(instance.storeState.getState()).toEqual({ count: 0 }); }); + it('should say if store exists already', () => { + const registry = new StoreRegistry(); + expect(registry.hasStore(StoreMock, 's1')).toBe(false); + registry.getStore(StoreMock, 's1'); + expect(registry.hasStore(StoreMock, 's1')).toBe(true); + }); + it('should get an existing store if no scopeId provided', () => { const registry = new StoreRegistry(); const instance1 = registry.getStore(StoreMock); diff --git a/src/store/create-state.js b/src/store/create-state.js index 07c876b..b0511dd 100644 --- a/src/store/create-state.js +++ b/src/store/create-state.js @@ -13,7 +13,7 @@ function createStoreState(key, initialState) { }, setState(nextState) { currentState = nextState; - // Instead of notifying all hooks immediately, we wait next tick + // Instead of notifying all handlers immediately, we wait next tick // so multiple actions affecting the same store gets combined schedule(storeState.notify); }, diff --git a/src/store/create.js b/src/store/create.js index 303d477..029fa37 100644 --- a/src/store/create.js +++ b/src/store/create.js @@ -9,7 +9,13 @@ function createKey(initialState, actions, name) { .join('__'); } -export function createStore({ name = '', initialState, actions }) { +export function createStore({ + name = '', + initialState, + actions, + containedBy, + handlers = {}, +}) { let key; return { get key() { @@ -18,5 +24,7 @@ export function createStore({ name = '', initialState, actions }) { }, initialState, actions, + containedBy, + handlers, }; } diff --git a/src/store/registry.js b/src/store/registry.js index 49e7ad3..ba1c7c3 100644 --- a/src/store/registry.js +++ b/src/store/registry.js @@ -20,6 +20,11 @@ export class StoreRegistry { return store; }; + hasStore = (Store, scopeId = this.defaultScope) => { + const key = this.generateKey(Store, scopeId); + return this.stores.has(key); + }; + getStore = (Store, scopeId = this.defaultScope) => { const key = this.generateKey(Store, scopeId); return this.stores.get(key) || this.initStore(key, Store); diff --git a/types/index.d.ts b/types/index.d.ts index 8eb25aa..355b0ac 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,5 +1,10 @@ declare module 'react-sweet-state' { - import { ComponentType, ReactNode, PropsWithChildren } from 'react'; + import type { + ComponentType, + ReactNode, + PropsWithChildren, + FunctionComponent, + } from 'react'; interface SetState { (newState: Partial): void; @@ -27,6 +32,13 @@ declare module 'react-sweet-state' { key: string; initialState: TState; actions: TActions; + containedBy?: SharedContainerComponent; + handlers?: { + onInit?: () => Action; + onUpdate?: () => Action; + onDestroy?: () => Action; + onContainerUpdate?: () => Action; + }; }; type StoreState = { @@ -89,6 +101,13 @@ declare module 'react-sweet-state' { store: Store, scopeId?: string ) => StoreInstance; + hasStore: < + TState, + TActions extends Record> + >( + store: Store, + scopeId?: string + ) => boolean; deleteStore: < TState, TActions extends Record> @@ -125,6 +144,15 @@ declare module 'react-sweet-state' { TProps >; + // eslint-disable-next-line @typescript-eslint/ban-types + interface SharedContainerComponent

+ extends FunctionComponent< + { + scope?: string; + isGlobal?: boolean; + } & P + > {} + type SubscriberComponent< TState, TActions, @@ -155,17 +183,38 @@ declare module 'react-sweet-state' { function createStore< TState extends object, - TActions extends Record> - >(config: { - initialState: TState; - actions: TActions; - name?: string; - }): Store; + TActions extends Record>, + TContainerProps = unknown + >( + config: + | { + initialState: TState; + actions: TActions; + name?: string; + containedBy?: never; + handlers?: never; + } + | { + initialState: TState; + actions: TActions; + name?: string; + containedBy: SharedContainerComponent; + handlers?: { + onInit?: () => Action; + onUpdate?: () => Action; + onDestroy?: () => Action; + onContainerUpdate?: () => Action; + }; + } + ): Store; /** * createContainer */ + function createContainer(options?: { + displayName?: string; + }): SharedContainerComponent; function createContainer< TState, TActions extends Record>, diff --git a/types/test.tsx b/types/test.tsx index cc9d92c..985813f 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -149,19 +149,25 @@ const actions = { }; // @ts-expect-error -const TypeStore0 = createStore({ count: 0 }); +const BasStore = createStore({ count: 0 }); -const TypeStore1 = createStore({ +createStore({ // @ts-expect-error initialState: { bla: 0 }, actions, }); // @ts-expect-error -const TypeStore2 = createStore({ initialState: { count: 0 } }); +createStore({ initialState: { count: 0 } }); // @ts-expect-error -const TypeStore3 = createStore({ initialState: '', actions }); +createStore({ initialState: '', actions }); + +// @ts-expect-error +createStore({ containedBy: createContainer(createStore(BasStore)) }); + +// @ts-expect-error +createStore({ initialState: {}, actions: {}, handlers: {} }); // Correct const TypeStore = createStore({ @@ -170,6 +176,13 @@ const TypeStore = createStore({ name: 'Type', }); +createStore({ + initialState: { count: 0 }, + actions, + name: 'Type', + containedBy: createContainer({ displayName: 'CB' }), +}); + /** * createSubscriber types tests */ @@ -441,3 +454,45 @@ Test = ( bla ); + +/** + * Shared Container types tests + */ + +const TypeSharedContainer = createContainer<{ initValue?: number }>(); + +Test = ( + // @ts-expect-error + {({ count }) => count} +); + +Test = ( + // @ts-expect-error + bla +); + +// Correct +Test = bla; +Test = bla; +Test = bla; +Test = bla; + +createStore({ + initialState: { count: 0 }, + actions: {}, + containedBy: TypeSharedContainer, + handlers: { + onInit: + () => + ({ setState }, { initValue }) => { + if (initValue) { + // @ts-expect-error Ensure type inferred properly + initValue.split; + setState({ count: initValue }); + } + }, + onUpdate: () => () => undefined, + onDestroy: () => () => undefined, + onContainerUpdate: () => () => undefined, + }, +}); From b1b98c919ea1e509cc00b3844c95ce0952f9d5ee Mon Sep 17 00:00:00 2001 From: Alberto Gasparin Date: Thu, 11 May 2023 17:10:25 +1000 Subject: [PATCH 2/4] Fix eslint config --- .eslintrc.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 3a1cb6e..e8a40db 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,7 @@ module.exports = { 'eslint:recommended', 'plugin:react/recommended', 'plugin:prettier/recommended', + 'plugin:flowtype/recommended', ], parserOptions: { ecmaFeatures: { @@ -44,7 +45,7 @@ module.exports = { { // Flow specific rules files: ['src/index.js.flow', '*/*flow.js', 'examples/*-flow/*/*.js'], - extends: ['plugin:flowtype/recommended'], + // extends: ['plugin:flowtype/recommended'], plugins: ['flowtype'], rules: { 'flowtype/generic-spacing': ['off'], From 26c60cb17a18f64b6315ae55e8b07cd4852f9265 Mon Sep 17 00:00:00 2001 From: Alberto Gasparin Date: Tue, 16 May 2023 12:41:54 +1000 Subject: [PATCH 3/4] Address PR feedback --- src/components/__tests__/integration.test.js | 35 +++++++++++------ src/components/container.js | 8 ++-- types/index.d.ts | 41 ++++++++++++-------- types/test.tsx | 8 +++- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/components/__tests__/integration.test.js b/src/components/__tests__/integration.test.js index 8ebcbe7..a9eeef3 100644 --- a/src/components/__tests__/integration.test.js +++ b/src/components/__tests__/integration.test.js @@ -407,25 +407,34 @@ describe('Integration', () => { expect(selector).toHaveBeenCalledTimes(2); }); - it.only('should capture all contained stores', async () => { - const onInit = jest.fn().mockReturnValue(() => {}); - const onUpdate = jest.fn().mockReturnValue(() => {}); - const onDestroy = jest.fn().mockReturnValue(() => {}); - const onContainerUpdate = jest.fn().mockReturnValue(() => {}); + it('should capture all contained stores', async () => { const SharedContainer = createContainer(); + const handlers1 = { + onInit: jest.fn().mockReturnValue(() => {}), + onUpdate: jest.fn().mockReturnValue(() => {}), + onDestroy: jest.fn().mockReturnValue(() => {}), + onContainerUpdate: jest.fn().mockReturnValue(() => {}), + }; const Store1 = createStore({ name: 'store', initialState: { todos: [], loading: false }, actions, containedBy: SharedContainer, - handlers: { onInit, onUpdate, onDestroy, onContainerUpdate }, + handlers: handlers1, }); + + const handlers2 = { + onInit: jest.fn().mockReturnValue(() => {}), + onUpdate: jest.fn().mockReturnValue(() => {}), + onDestroy: jest.fn().mockReturnValue(() => {}), + onContainerUpdate: jest.fn().mockReturnValue(() => {}), + }; const Store2 = createStore({ name: 'two', initialState: {}, actions, containedBy: SharedContainer, - handlers: { onInit, onUpdate, onDestroy, onContainerUpdate }, + handlers: handlers2, }); const Subscriber = createSubscriber(Store1); const Subscriber2 = createSubscriber(Store2); @@ -441,20 +450,24 @@ describe('Integration', () => { const { rerender, unmount } = render(); - expect(onInit).toHaveBeenCalledTimes(2); + expect(handlers1.onInit).toHaveBeenCalledTimes(1); + expect(handlers2.onInit).toHaveBeenCalledTimes(1); expect(defaultRegistry.stores.size).toEqual(0); act(() => acts.add('todo2')); - expect(onUpdate).toHaveBeenCalledTimes(1); + expect(handlers1.onUpdate).toHaveBeenCalledTimes(1); + expect(handlers2.onUpdate).toHaveBeenCalledTimes(0); rerender(); - expect(onContainerUpdate).toHaveBeenCalledTimes(2); + expect(handlers1.onContainerUpdate).toHaveBeenCalledTimes(1); + expect(handlers2.onContainerUpdate).toHaveBeenCalledTimes(1); unmount(); await actTick(); - expect(onDestroy).toHaveBeenCalledTimes(2); + expect(handlers1.onDestroy).toHaveBeenCalledTimes(1); + expect(handlers2.onDestroy).toHaveBeenCalledTimes(1); }); }); diff --git a/src/components/container.js b/src/components/container.js index 5defcc4..941907e 100644 --- a/src/components/container.js +++ b/src/components/container.js @@ -229,8 +229,8 @@ function useContainedStore(scope, registry, props, override) { // eslint-disable-next-line react-hooks/exhaustive-deps const containedStores = useMemo(() => new Map(), [scope]); - // Store props in a ref to avoid re-binding actions when they change - // If devs want to update consumers on prop change, they can put them in store + // Store props in a ref to avoid re-binding actions when they change and re-rendering all + // consumers unnecessarily. The update is handled by an effect on the component instead const containerProps = useRef(); containerProps.current = props; @@ -322,7 +322,8 @@ function createFunctionContainer({ displayName, override } = {}) { // This is sub-optimal as if there are other containers with the same // old scope id we will re-render those too, but still better than context storeState.notify(); - // Schedule check if instance has still subscribers, if not delete + // Given unsubscription is handled by useSyncExternalStore, we have no control on when + // React decides to do it. So we schedule on next tick to run last Promise.resolve().then(() => { if ( !storeState.listeners().length && @@ -335,6 +336,7 @@ function createFunctionContainer({ displayName, override } = {}) { }); } ); + // no need to reset containedStores as the map is already bound to scope }; }, [registry, scope, containedStores]); diff --git a/types/index.d.ts b/types/index.d.ts index 355b0ac..b5225cb 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -32,7 +32,7 @@ declare module 'react-sweet-state' { key: string; initialState: TState; actions: TActions; - containedBy?: SharedContainerComponent; + containedBy?: ContainerComponent; handlers?: { onInit?: () => Action; onUpdate?: () => Action; @@ -136,22 +136,31 @@ declare module 'react-sweet-state' { function batch(callback: () => any): void; - type ContainerComponent = ComponentType< - PropsWithChildren<{ - scope?: string; - isGlobal?: boolean; - }> & - TProps - >; + type ContainerComponent = + | GenericContainerComponent + | OverrideContainerComponent; - // eslint-disable-next-line @typescript-eslint/ban-types - interface SharedContainerComponent

+ interface GenericContainerComponent extends FunctionComponent< - { + PropsWithChildren<{ scope?: string; isGlobal?: boolean; - } & P - > {} + }> & + TProps + > { + override?: false; + } + + interface OverrideContainerComponent + extends FunctionComponent< + PropsWithChildren<{ + scope?: string; + isGlobal?: boolean; + }> & + TProps + > { + override: true; + } type SubscriberComponent< TState, @@ -198,7 +207,7 @@ declare module 'react-sweet-state' { initialState: TState; actions: TActions; name?: string; - containedBy: SharedContainerComponent; + containedBy: GenericContainerComponent; handlers?: { onInit?: () => Action; onUpdate?: () => Action; @@ -214,7 +223,7 @@ declare module 'react-sweet-state' { function createContainer(options?: { displayName?: string; - }): SharedContainerComponent; + }): GenericContainerComponent; function createContainer< TState, TActions extends Record>, @@ -227,7 +236,7 @@ declare module 'react-sweet-state' { onCleanup?: () => Action; displayName?: string; } - ): ContainerComponent; + ): OverrideContainerComponent; /** * createSubscriber diff --git a/types/test.tsx b/types/test.tsx index 985813f..5db8092 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -163,8 +163,12 @@ createStore({ initialState: { count: 0 } }); // @ts-expect-error createStore({ initialState: '', actions }); -// @ts-expect-error -createStore({ containedBy: createContainer(createStore(BasStore)) }); +createStore({ + initialState: { count: 0 }, + actions, + // @ts-expect-error Cannot use override container as containedBy + containedBy: createContainer(createStore({} as any)), +}); // @ts-expect-error createStore({ initialState: {}, actions: {}, handlers: {} }); From dd054a3ac539ef4b52b78e4c1257cc2c28d6d1fc Mon Sep 17 00:00:00 2001 From: Alberto Gasparin Date: Tue, 16 May 2023 12:46:15 +1000 Subject: [PATCH 4/4] Docs --- docs/_sidebar.md | 2 +- docs/advanced/README.md | 1 - docs/advanced/container.md | 49 ++++++++++++++------- docs/advanced/rehydration.md | 27 ------------ docs/api/container.md | 85 +++++++++++++++++++++++++++--------- docs/api/store.md | 37 ++++++++++++---- docs/basics/store.md | 23 +++++++++- docs/recipes/README.md | 1 + docs/recipes/flow.md | 7 +-- docs/recipes/rehydration.md | 32 ++++++++++++++ docs/recipes/typescript.md | 7 ++- 11 files changed, 190 insertions(+), 81 deletions(-) delete mode 100644 docs/advanced/rehydration.md create mode 100644 docs/recipes/rehydration.md diff --git a/docs/_sidebar.md b/docs/_sidebar.md index c80cd71..ce468b1 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -16,7 +16,6 @@ - [Selectors](/advanced/selector.md) - [Containers](/advanced/container.md) - [Devtools](/advanced/devtools.md) - - [State rehydration](/advanced/rehydration.md) - [Middlewares](/advanced/middlewares.md) - [Custom mutator](/advanced/mutator.md) @@ -34,6 +33,7 @@ * **Recipes** + - [State rehydration](/recipes/rehydration.md) - [Flow types](/recipes/flow.md) - [Typescript types](/recipes/typescript.md) - [Composition](/recipes/composition.md) diff --git a/docs/advanced/README.md b/docs/advanced/README.md index f09674b..f2dccfc 100644 --- a/docs/advanced/README.md +++ b/docs/advanced/README.md @@ -4,6 +4,5 @@ - [Selectors](./selector.md) - [Containers](./container.md) - [Devtools](./devtools.md) -- [State rehydration](./rehydration.md) - [Middlewares](./middlewares.md) - [Custom mutator](./mutator.md) diff --git a/docs/advanced/container.md b/docs/advanced/container.md index 6f94629..2649ecf 100644 --- a/docs/advanced/container.md +++ b/docs/advanced/container.md @@ -1,12 +1,17 @@ ## Containers -While sweet-state promotes independent, globally accessible micro-Stores, such behaviour only allows one instance for each Store type. Sometimes though you might want to have multiple instances of the same Store, and that's where `Container` components come into play. They allow you to create independent Store instances of the same type, making them either globally available (app-wide) or just locally (so only accessible to children hooks/subscribers). +> _Note: we recently improved Containers API to be more flexibile and safe. These docs now use the new Store's `containedBy` and `handlers` attributes. You still can find the old one documented [here](../api/container.md)._ + +While sweet-state promotes independent, globally accessible micro-Stores, such behaviour only allows one instance for each Store type and might become problematic as your app grows. As an example, you might want to have multiple instances of the same Store, or some store data to be cleaned up once a component unmounts. That's where `Container` components come into play. They allow you to create independent Store instances of the same type, making them either globally available (app-wide) or just locally (so only accessible to children hooks/subscribers). ```js // components/counter.js import { createStore, createContainer, createHook } from 'react-sweet-state'; +export const CounterContainer = createContainer(); + const Store = createStore({ + containedBy: CounterContainer, initialState: { count: 0 }, actions: { increment: @@ -18,7 +23,6 @@ const Store = createStore({ }, }); -export const CounterContainer = createContainer(Store); const useCounter = createHook(Store); export const CounterButton = () => { @@ -54,7 +58,7 @@ const App = () => ( ); ``` -The power of `Container` is that you can expand or reduce the scope at will, without requiring any change on the children. That means you can start local and later, if you need to access the same state elsewhere, you can either move the `Container` up in the tree, add the `scope` prop to "link" two separate trees or remove the container altogether. +The power of `Container` is that you can expand or reduce the scope at will, without requiring any change on the children. That means you can start local and later, if you need to access the same state elsewhere, you can either move the `Container` up in the tree or add the `scope` prop to "link" two separate trees or add `isGlobal` and make it singleton once again. ### Additional features @@ -80,10 +84,11 @@ const App = () => ( #### Container props are available in actions -Props provided to containers are passed to Store actions as a second parameter [see actions API](../api/actions.md). This makes it extremely easy to pass dynamic configuration options to actions. +Props provided to containers are passed to Store `actions` and `handlers` as a second parameter [see actions API](../api/actions.md). This makes it extremely easy to pass dynamic configuration options to actions. ```js const Store = createStore({ + containedBy: CounterContainer, initialState: { count: 0 }, actions: { increment: @@ -105,23 +110,35 @@ const App = () => ( ); ``` -_NOTE: Remember though that those props will **only** be available to hooks/subscribers that are children of the `Container` that receives them, regardless of the Container being global/scoped._ +> _NOTE: Remember though that those props will **only** be available to hooks/subscribers that are children of the `Container` that receives them, regardless of the Container being global/scoped._ -#### Container can trigger actions +#### Container enables additional Store handlers -`Container` options have `onInit` and `onUpdate` keys, to trigger actions and update the state on its props change. The methods' shape is the same as all other actions. +By providing `containedBy` to a store you can enable additional `handlers` that trigger functions when specific events occur. Current supported handlers are: `onInit`, `onUpdate`, `onDestroy` and `onContainerUpdate`, and these methods' shape is the same as all other actions. ```js -const CounterContainer = createContainer(Store, { - onInit: - () => - ({ setState }, { initialCount }) => { - setState({ count: initialCount }); - }, +const Store = createStore({ + containedBy: CounterContainer, + initialState: { count: 0 }, + actions: { + increment: + () => + ({ setState }, { multiplier }) => { + const currentCount = getState().count * multiplier; + setState({ count: currentCount + 1 }); + }, + }, + handlers: { + onContainerUpdate: + () => + ({ setState }, { multiplier }) => { + setState({ count: 0 }); // reset state on multiplier change + }, + }, }); -const App = () => ( - +const App = ({ n }) => ( + {/* this starts from 10 */} @@ -130,7 +147,7 @@ const App = () => ( #### Scoped data cleanup -Store instances created by `Container`s without `isGlobal` are automatically cleared once the last Container accessing that Store is unmounted. +Store instances created by `Container`s without `isGlobal` are automatically cleared once the last Container accessing that Store is unmounted. At that point `handlers.onDestroy` will also be called. --- diff --git a/docs/advanced/rehydration.md b/docs/advanced/rehydration.md deleted file mode 100644 index bb630e9..0000000 --- a/docs/advanced/rehydration.md +++ /dev/null @@ -1,27 +0,0 @@ -#### Store state hydration - -Initialising a Store with data can be very useful. Particularly important when server-side rendering your application. To make this happen you can render a `Container` configured with an `onInit` action and passing it your initial state as a `prop` (we assume the initial state data is readily available in your render context). - -##### Hydrate through a Container - -As seen in use in the `Container` chapter: - -```js -const CounterContainer = createContainer(Store, { - onInit: - () => - ({ setState }, { initialCount }) => { - setState({ count: initialCount }); - }, -}); - -const App = () => ( - - {/* ... */} - -); -``` - -The `Container` is configured with an `onInit` action that takes the initial state prop and stores it. This will make the initial state immediately available to all hooks/subscribers executing after, avoiding unnecessary re-renders. - -_Note: remember to manage in the Container's `onInit` any checks for previous state existence or runtime environment._ diff --git a/docs/api/container.md b/docs/api/container.md index 1a6f2f1..bc204b2 100644 --- a/docs/api/container.md +++ b/docs/api/container.md @@ -3,23 +3,17 @@ ### createContainer ```js -createContainer(Store, [options]); +createContainer([options]); ``` -##### Arguments +This is the recommended way of creating containers, passing them as `containedBy` attribute on store creation. -1. `Store` _(Object)_: The store type returned from a call to `createStore` +##### Arguments -2. [`options`] _(Object)_: containing one or more of the following keys: +1. [`options`] _(Object)_: containing one or more of the following keys: - `displayName` _(string)_: Used by React to better identify a component. Defaults to `Container(${storeName})` - - `onInit` _(Function)_: an action that will be triggered on container initialisation. If you define multiple containers this action will be run each time one of the container components is initialised by React. - - - `onUpdate` _(Function)_: an action that will be triggered when props on a container change. - - - `onCleanup` _(Function)_: an action that will be triggered after the container has been unmounted. Useful in case you want to clean up side effects like event listeners or timers, or restore the store state to its initial state. As with `onInit`, if you define multiple containers this action will trigger after unmount of each one. - ##### Returns _(Component)_: this React component allows you to change the behaviour of child components by providing different Store instances or custom props to actions. It accept the following props: @@ -32,23 +26,74 @@ _(Component)_: this React component allows you to change the behaviour of child ##### Example -Let's create a Container that automatically populates the todos' Store instance with some todos coming from SSR, for instance. +Let's create a Container that initializes all theme-related stores: ```js +// theming.js import { createContainer } from 'react-sweet-state'; -import Store from './store'; +export const ThemeContainer = createContainer(); + +// colors.js +import { ThemeContainer } from './theming'; +const ColorsStore = createStore({ + // ... + containedBy: ThemeContainer, +}); +// We can also have a FontSizesStore that has the same `containedBy` value + +// app.js +const UserTheme = ({ colors, sizes }) => ( + + + +); +``` + +### createContainer as override + +> _Note: this API configuration provides less flexibility and safety nets than using Store's `containedBy` and `handlers`, so we recommend using this style mostly for testing/storybook purposes._ + +```js +createContainer(Store, [options]); +``` -const TodosContainer = createContainer(Store, { +##### Arguments + +1. `Store` _(Object)_: The store type returned from a call to `createStore` + +2. [`options`] _(Object)_: containing one or more of the following keys: + + - `displayName` _(string)_: Used by React to better identify a component. Defaults to `Container(${storeName})`. + + - `onInit` _(Function)_: an action that will be triggered on store initialisation. It overrides store's `handlers.onInit`. + + - `onUpdate` _(Function)_: an action that will be triggered when props on a container change. It is different from store's `onUpdate` API. It overrides store's `handlers.onContainerUpdate`. + + - `onCleanup` _(Function)_: an action that will be triggered after the container has been unmounted and no more consumers of the store instance are present. Useful in case you want to clean up side effects like event listeners or timers. It overrides store's `handlers.onDestroy`. + +##### Example + +Let's create a container that provides an initial state on tests: + +```js +import { createContainer } from 'react-sweet-state'; +import { ColorsStore } from './colors'; + +const ColorsContainer = createContainer(ColorsStore, { onInit: () => - ({ setState }, { initialTodos }) => { - setState({ todos: initialTodos }); + ({ setState }, { initialColor }) => { + setState({ color: initialColor }); }, }); -const UserTodos = ({ initialTodos }) => ( - - - -); +it('should render with right color', () => { + const mockColor = 'white'; + const { asFragment } = render( + + + + ); + expect(asFragment()).toMatchSnapshot(); +}); ``` diff --git a/docs/api/store.md b/docs/api/store.md index b63e930..a2f49a4 100644 --- a/docs/api/store.md +++ b/docs/api/store.md @@ -16,27 +16,46 @@ createStore(config); - `name` _(string)_: optional, useful for debugging and to generate more meaningful store keys. + - `containedBy` _(Container)_: optional, specifies the Container component that should handle the store boundary + + - `handlers` _(object)_: optional, defines callbacks on specific events + + - `onInit` _(Function)_: action triggered on store initialisation + - `onUpdate` _(Function)_: action triggered on store update + - `onDestroy` _(Function)_: action triggered on store destroy + - `onContainerUpdate` _(Function)_: action triggered when `containedBy` container props change + ##### Returns -_(Object)_: used to create Containers, hooks and Subscribers related to the same store type +_(Object)_: used to create hooks, Subscribers and override Containers, related to the same store type ##### Example -Let's create a Container that automatically populates the todos' Store instance with some todos coming from SSR, for instance. +Let's create a Store with an action that loads the todos' and triggers it on store initialisation ```js -import { createContainer } from 'react-sweet-state'; -import Store from './store'; +import { createStore } from 'react-sweet-state'; +import { TodosContainer } from './container'; + +const actions = { + load: + () => + async ({ setState }) => { + const todos = await fetch('/todos'); + setState({ todos }); + }, +}; const Store = createStore({ name: 'todos', initialState: { todos: [] }, - actions: { - load: + actions, + containedBy: TodosContainer, + handlers: { + onInit: () => - async ({ setState }) => { - const todos = await fetch('/todos'); - setState({ todos }); + async ({ dispatch }) => { + await dispatch(actions.load()); }, }, }); diff --git a/docs/basics/store.md b/docs/basics/store.md index 5a9ae6a..d95a976 100644 --- a/docs/basics/store.md +++ b/docs/basics/store.md @@ -24,10 +24,29 @@ const actions = { const Store = createStore({ initialState, actions }); ``` -Optionally, you can add a unique `name` property to the `createStore` configuration object. It will be used as the displayName in Redux Devtools. +Optionally, you can add to the `createStore` configuration object a unique `name` property, a bound to a Container component via `containedBy` property, and a series of `handlers` to trigger actions on specific events. ```js -const Store = createStore({ initialState, actions, name: 'counter' }); +const Store = createStore({ + initialState, + actions, + name: 'counter', + containedBy: StoreContainer, + handlers: { + onInit: + () => + ({ setState }, containerProps) => {}, + onUpdate: + () => + ({ setState }, containerProps) => {}, + onDestroy: + () => + ({ setState }, containerProps) => {}, + onContainerUpdate: + () => + ({ setState }, containerProps) => {}, + }, +}); ``` The first time a hook or a `Container` linked to this store is rendered, a Store instance will be initialised and its state shared across all components created from the same Store. If you need multiple instances of the same Store, use the `Container` component ([see Container docs for more](../advanced/container.md)). diff --git a/docs/recipes/README.md b/docs/recipes/README.md index d401c94..c55391f 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -1,5 +1,6 @@ ## Recipes +- [State rehydration](./rehydration.md) - [Flow types](./flow.md) - [Typescript types](./typescript.md) - [Composition](./composition.md) diff --git a/docs/recipes/flow.md b/docs/recipes/flow.md index 5a93887..f2134a5 100644 --- a/docs/recipes/flow.md +++ b/docs/recipes/flow.md @@ -31,7 +31,10 @@ const actions = { }, }; +const CounterContainer: ContainerComponent<{}> = createContainer(); + const Store = createStore({ + containedBy: CounterContainer, initialState, actions, }); @@ -39,7 +42,6 @@ const Store = createStore({ const CounterSubscriber: SubscriberComponent = createSubscriber(Store); const useCounter: HookFunction = createHook(Store); -const CounterContainer: ContainerComponent<{}> = createContainer(Store); ``` #### Actions pattern @@ -117,6 +119,5 @@ If your container requires additional props: type ContainerProps = { multiplier: number }; // this component requires props -const CounterContainer: ContainerComponent = - createContainer(Store); +const CounterContainer: ContainerComponent = createContainer(); ``` diff --git a/docs/recipes/rehydration.md b/docs/recipes/rehydration.md new file mode 100644 index 0000000..23cbb3b --- /dev/null +++ b/docs/recipes/rehydration.md @@ -0,0 +1,32 @@ +#### Store state hydration + +Initialising a Store with data can be very useful, particularly when server-side rendering your application. To make this happen you can use a `Container` passing it your initial state as a `prop` (we assume the initial state data is readily available in your render context) and then use Store's `handlers.onInit` to populate the state accordingly. + +##### Hydrate through a Container + +As seen in use in the `Container` chapter: + +```js +const CounterContainer = createContainer(); + +const Store = createStore({ + containedBy: CounterContainer, + initialState: { count: 0 }, + actions: {}, + handlers: { + onInit: + () => + ({ setState }, { initialCount }) => { + setState({ count: initialCount }); + }, + }, +}); + +const App = () => ( + + {/* ... */} + +); +``` + +The benefit of this approach compared to triggering an `useEffect` action is that the initial state will be immediately available to all hooks/subscribers executing after, avoiding unnecessary re-renders. diff --git a/docs/recipes/typescript.md b/docs/recipes/typescript.md index b598341..29d0db5 100644 --- a/docs/recipes/typescript.md +++ b/docs/recipes/typescript.md @@ -28,14 +28,17 @@ const actions = { }, }; +const CounterContainer = createContainer(); + +// Note: most times TS will be able to infer the generics const Store = createStore({ initialState, actions, + containedBy: CounterContainer, }); const CounterSubscriber = createSubscriber(Store); const useCounter = createHook(Store); -const CounterContainer = createContainer(Store); ``` You don't have to manually type all the `create*` methods, as they can be inferred for most use cases. @@ -101,5 +104,5 @@ If your container requires additional props: type ContainerProps = { multiplier: number }; // this component requires props -const CounterContainer = createContainer(Store); +const CounterContainer = createContainer(); ```