From 1f0cb01e507b1967d88e80f8e82d2049588cf2e7 Mon Sep 17 00:00:00 2001 From: Alberto Gasparin Date: Tue, 30 May 2023 13:23:48 +0200 Subject: [PATCH] Cross store actions --- examples/advanced-shared/components/color.tsx | 9 +- examples/advanced-shared/components/width.tsx | 13 ++ examples/advanced-shared/index.tsx | 12 +- src/__tests__/integration.test.js | 156 ++++++++++++- src/components/__tests__/container.test.js | 32 ++- src/components/__tests__/hook.test.js | 6 +- src/components/container.js | 207 ++---------------- src/components/hook.js | 2 +- src/context.js | 13 +- src/store/__tests__/bind-actions.test.js | 69 +++++- src/store/bind-actions.js | 4 + src/store/registry.js | 6 +- types/index.d.ts | 7 + 13 files changed, 327 insertions(+), 209 deletions(-) diff --git a/examples/advanced-shared/components/color.tsx b/examples/advanced-shared/components/color.tsx index e8a1352..5044cb0 100644 --- a/examples/advanced-shared/components/color.tsx +++ b/examples/advanced-shared/components/color.tsx @@ -13,15 +13,20 @@ const initialState: State = { color: 'white', }; -const actions = { +export const actions = { set: (color: string) => ({ setState }: StoreActionApi) => { setState({ color }); }, + reset: + () => + ({ setState }: StoreActionApi) => { + setState(initialState); + }, }; -const Store = createStore({ +export const Store = createStore({ initialState, actions, containedBy: ThemingContainer, diff --git a/examples/advanced-shared/components/width.tsx b/examples/advanced-shared/components/width.tsx index 604bcd7..c78c9ba 100644 --- a/examples/advanced-shared/components/width.tsx +++ b/examples/advanced-shared/components/width.tsx @@ -4,6 +4,7 @@ import { type StoreActionApi, } from 'react-sweet-state'; import { ThemingContainer } from './theming'; +import { Store as ColorStore, actions as colorActions } from './color'; type State = { width: number; @@ -19,6 +20,18 @@ const actions = { ({ setState }: StoreActionApi) => { setState({ width }); }, + reset: + () => + ({ setState }: StoreActionApi) => { + setState(initialState); + }, + + resetAll: + () => + ({ dispatch, dispatchTo }: StoreActionApi) => { + dispatch(actions.reset()); + dispatchTo(ColorStore, colorActions.reset()); + }, }; const Store = createStore({ diff --git a/examples/advanced-shared/index.tsx b/examples/advanced-shared/index.tsx index 9ef5ca2..87b5df5 100644 --- a/examples/advanced-shared/index.tsx +++ b/examples/advanced-shared/index.tsx @@ -4,18 +4,21 @@ import ReactDOM from 'react-dom/client'; import { useColor } from './components/color'; import { useWidth } from './components/width'; import { ThemingContainer } from './components/theming'; +import { defaults } from 'react-sweet-state'; -const colors = ['white', 'aliceblue', 'beige', 'gainsboro', 'honeydew']; -const widths = [200, 220, 240, 260, 280]; +defaults.devtools = true; + +const colors = ['aliceblue', 'beige', 'gainsboro', 'honeydew']; +const widths = [220, 240, 260, 280]; const rand = () => Math.floor(Math.random() * colors.length); -const initialData = { color: colors[rand()], width: widths[rand()] }; +const initialData = { color: 'white', width: 200 }; /** * Components */ const ThemeHook = ({ title }: { title: string }) => { const [{ color }, { set: setColor }] = useColor(); - const [{ width }, { set: setWidth }] = useWidth(); + const [{ width }, { set: setWidth, resetAll }] = useWidth(); return (
@@ -24,6 +27,7 @@ const ThemeHook = ({ title }: { title: string }) => {

Width: {width}

+
); }; diff --git a/src/__tests__/integration.test.js b/src/__tests__/integration.test.js index 6014ee7..85de011 100644 --- a/src/__tests__/integration.test.js +++ b/src/__tests__/integration.test.js @@ -181,9 +181,7 @@ describe('Integration', () => { const state3 = { loading: false, todos: ['todoB'] }; const call3 = 3; - // 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(children1.mock.calls[call3]).toEqual([state3, expectActions]); expect(children2.mock.calls[call3]).toEqual([state3, expectActions]); }); @@ -487,4 +485,156 @@ describe('Integration', () => { }).toThrow(/should be contained/); errorSpy.mockRestore(); }); + + describe('dispatchTo', () => { + const createTestElements = ({ mainContainer, otherContainer }) => { + const actionOther = + (n) => + ({ setState }, { plus }) => + setState({ count: n, plus }); + const StoreOther = createStore({ + name: 'store-other', + containedBy: otherContainer, + initialState: {}, + actions: { set: actionOther }, + }); + const StoreMain = createStore({ + name: 'store-main', + containedBy: mainContainer, + initialState: {}, + actions: { + setOther: + (n) => + ({ dispatchTo }) => + dispatchTo(StoreOther, actionOther(n)), + }, + }); + + const MainSubscriber = createSubscriber(StoreMain); + const OtherSubscriber = createSubscriber(StoreOther); + const mainSpy = jest.fn().mockReturnValue(null); + const otherSpy = jest.fn().mockReturnValue(null); + + const Content = () => ( + <> + {mainSpy} + {otherSpy} + + ); + return { + Content, + StoreMain, + mainReturn: (n = 0) => mainSpy.mock.calls[n], + otherReturn: (n = 0) => otherSpy.mock.calls[n], + }; + }; + + it('should allow dispatchTo global -> global', () => { + const { Content, mainReturn, otherReturn } = createTestElements({ + mainContainer: null, + otherContainer: null, + }); + + render(); + const [, mainActions] = mainReturn(0); + act(() => mainActions.setOther(1)); + + expect(otherReturn(1)).toEqual([{ count: 1 }, expect.any(Object)]); + }); + + it('should allow dispatchTo contained -> contained', () => { + const SharedContainer = createContainer(); + const { Content, mainReturn, otherReturn } = createTestElements({ + mainContainer: SharedContainer, + otherContainer: SharedContainer, + }); + + render( + + + + ); + const [, mainActions] = mainReturn(0); + act(() => mainActions.setOther(1)); + + expect(otherReturn(1)).toEqual([{ count: 1 }, expect.any(Object)]); + }); + + it('should allow dispatchTo contained -> global', () => { + const MainContainer = createContainer(); + const { Content, mainReturn, otherReturn } = createTestElements({ + mainContainer: MainContainer, + otherContainer: null, + }); + + render( + + + + ); + const [, mainActions] = mainReturn(0); + act(() => mainActions.setOther(1)); + + expect(otherReturn(1)).toEqual([{ count: 1 }, expect.any(Object)]); + }); + + it('should allow dispatchTo global -> contained if properly contained', () => { + const OtherContainer = createContainer({ displayName: 'OtherContainer' }); + const { Content, mainReturn, otherReturn } = createTestElements({ + mainContainer: null, + otherContainer: OtherContainer, + }); + + render( + + + + ); + const [, mainActions] = mainReturn(0); + act(() => mainActions.setOther(1)); + + expect(otherReturn(1)).toEqual([{ count: 1 }, expect.any(Object)]); + }); + + it('should allow dispatchTo contained -> other contained', async () => { + const MainContainer = createContainer(); + const OtherContainer = createContainer(); + + const { Content, mainReturn, otherReturn } = createTestElements({ + mainContainer: MainContainer, + otherContainer: OtherContainer, + }); + + render( + + + + + + ); + const [, mainActions] = mainReturn(0); + act(() => mainActions.setOther(1)); + + expect(otherReturn(1)).toEqual([{ count: 1 }, expect.any(Object)]); + }); + + it('should allow dispatchTo override -> contained', async () => { + const { Content, StoreMain, mainReturn, otherReturn } = + createTestElements({ + mainContainer: null, + otherContainer: null, + }); + const OverrideContainer = createContainer(StoreMain); + + render( + + + + ); + const [, mainActions] = mainReturn(0); + act(() => mainActions.setOther(1)); + + expect(otherReturn(1)).toEqual([{ count: 1 }, expect.any(Object)]); + }); + }); }); diff --git a/src/components/__tests__/container.test.js b/src/components/__tests__/container.test.js index f2ca5a3..0db093d 100644 --- a/src/components/__tests__/container.test.js +++ b/src/components/__tests__/container.test.js @@ -60,12 +60,6 @@ describe('Container', () => { describe('createContainer', () => { it('should return a Container component', () => { expect(Container.displayName).toEqual('Container(test)'); - expect(Container.storeType).toEqual(Store); - expect(Container.hooks).toEqual({ - onInit: expect.any(Function), - onUpdate: expect.any(Function), - onCleanup: expect.any(Function), - }); }); }); @@ -88,7 +82,11 @@ describe('Container', () => { const children = {() => null}; render({children}); - expect(defaultRegistry.getStore).toHaveBeenCalledWith(Store, 's1'); + expect(defaultRegistry.getStore).toHaveBeenCalledWith( + Store, + 's1', + expect.any(Function) + ); expect(mockLocalRegistry.getStore).not.toHaveBeenCalled(); }); @@ -110,7 +108,11 @@ describe('Container', () => { ); - expect(defaultRegistry.getStore).toHaveBeenCalledWith(Store, 's2'); + expect(defaultRegistry.getStore).toHaveBeenCalledWith( + Store, + 's2', + expect.any(Function) + ); }); it('should get local storeState if local matching', () => { @@ -118,7 +120,11 @@ describe('Container', () => { const children = {() => null}; render({children}); - expect(mockLocalRegistry.getStore).toHaveBeenCalledWith(Store, undefined); + expect(mockLocalRegistry.getStore).toHaveBeenCalledWith( + Store, + undefined, + expect.any(Function) + ); expect(defaultRegistry.getStore).not.toHaveBeenCalled(); }); @@ -127,7 +133,11 @@ describe('Container', () => { const children = {() => null}; render({children}); - expect(defaultRegistry.getStore).toHaveBeenCalledWith(Store, undefined); + expect(defaultRegistry.getStore).toHaveBeenCalledWith( + Store, + undefined, + expect.any(Function) + ); expect(mockLocalRegistry.getStore).not.toHaveBeenCalled(); }); @@ -229,6 +239,7 @@ describe('Container', () => { setState: expect.any(Function), actions: expect.any(Object), dispatch: expect.any(Function), + dispatchTo: expect.any(Function), }, { defaultCount: 5 } ); @@ -256,6 +267,7 @@ describe('Container', () => { setState: expect.any(Function), actions: expect.any(Object), dispatch: expect.any(Function), + dispatchTo: expect.any(Function), }, { defaultCount: 6 } ); diff --git a/src/components/__tests__/hook.test.js b/src/components/__tests__/hook.test.js index 6a087e2..4114469 100644 --- a/src/components/__tests__/hook.test.js +++ b/src/components/__tests__/hook.test.js @@ -61,7 +61,11 @@ describe('Hook', () => { it('should get the storeState from registry', () => { const { getRender } = setup(); getRender(); - expect(defaultRegistry.getStore).toHaveBeenCalledWith(StoreMock); + expect(defaultRegistry.getStore).toHaveBeenCalledWith( + StoreMock, + undefined, + expect.any(Function) + ); }); it('should render children with store data and actions', () => { diff --git a/src/components/container.js b/src/components/container.js index 2acabe1..726a7ec 100644 --- a/src/components/container.js +++ b/src/components/container.js @@ -1,5 +1,4 @@ import React, { - Component, useCallback, useContext, useMemo, @@ -9,175 +8,10 @@ import React, { import PropTypes from 'prop-types'; import { Context } from '../context'; -import { StoreRegistry, bindActions, defaultRegistry } from '../store'; -import shallowEqual from '../utils/shallow-equal'; +import { StoreRegistry, bindActions } from '../store'; const noop = () => () => {}; -export default class Container extends Component { - static propTypes = { - children: PropTypes.node, - scope: PropTypes.string, - isGlobal: PropTypes.bool, - }; - - static storeType = null; - static hooks = null; - static contextType = Context; - - static getDerivedStateFromProps(nextProps, prevState) { - const { scope } = nextProps; - const hasScopeChanged = scope !== prevState.scope; - - let nextState = null; - if (hasScopeChanged) { - const actions = prevState.bindContainerActions(scope); - nextState = { - scope, - scopedActions: actions, - }; - } - // We trigger the action here so subscribers get new values ASAP - prevState.triggerContainerAction(nextProps); - return nextState; - } - - constructor(props, context) { - super(props, context); - - const { - // These fallbacks are needed only to make enzyme shallow work - // as it does not fully support provider-less Context enzyme#1553 - globalRegistry = defaultRegistry, - retrieveStore = defaultRegistry.getStore, - } = this.context; - - this.state = { - api: { - globalRegistry, - retrieveStore: (Store, scope) => - this.constructor.storeType === Store - ? this.getScopedStore(scope) - : retrieveStore(Store), - }, - // stored to make them available in getDerivedStateFromProps - // as js context there is null https://github.com/facebook/react/issues/12612 - bindContainerActions: this.bindContainerActions, - triggerContainerAction: this.triggerContainerAction, - scope: props.scope, - }; - this.state.scopedActions = this.bindContainerActions(props.scope); - } - - registry = new StoreRegistry('__local__'); - scopedHooks = {}; - - componentDidUpdate(prevProps) { - if (this.props.scope !== prevProps.scope) { - const { storeState } = this.getScopedStore(prevProps.scope); - // Trigger a forced update on all subscribers as render might have been blocked - // When called, subscribers that have already re-rendered with the new scope - // are no longer subscribed to the old one, so we "force update" the remaining. - // 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 using context - storeState.notify(); - // Schedule check if instance has still subscribers, if not delete - Promise.resolve().then(() => { - this.deleteScopedStore(storeState, prevProps.scope); - }); - } - } - - componentWillUnmount() { - let scopedStore = this.props.scope ? this.getScopedStore() : null; - // schedule on next tick as this is called by React before useEffect cleanup - // so if we run immediately listeners will still be there and run - Promise.resolve().then(() => { - this.scopedHooks.onCleanup(); - // Check if scope has still subscribers, if not delete - if (scopedStore) this.deleteScopedStore(scopedStore.storeState); - }); - } - - bindContainerActions = (scope) => { - const { storeType, hooks } = this.constructor; - const { api } = this.state; - // we explicitly pass scope as it might be changed - const { storeState } = api.retrieveStore(storeType, scope); - - const config = { - props: () => this.actionProps, - contained: (s) => storeType === s, - }; - - const actions = bindActions(storeType.actions, storeState, config); - this.scopedHooks = bindActions(hooks, storeState, config, actions); - - // make sure we also reset actionProps - this.actionProps = null; - return actions; - }; - - triggerContainerAction = (nextProps) => { - const nextActionProps = this.filterActionProps(nextProps); - const prevActionProps = this.actionProps; - if (shallowEqual(prevActionProps, nextActionProps)) return; - - // store restProps on instance so we can diff and use fresh props - // in actions even before react sets them in this.props - this.actionProps = nextActionProps; - - if (this.scopedHooks.onInit) { - this.scopedHooks.onInit(); - this.scopedHooks.onInit = null; - } else { - this.scopedHooks.onUpdate(); - } - }; - - filterActionProps = (props) => { - // eslint-disable-next-line no-unused-vars - const { children, scope, isGlobal, ...restProps } = props; - return restProps; - }; - - getRegistry() { - const isLocal = !this.props.scope && !this.props.isGlobal; - return isLocal ? this.registry : this.state.api.globalRegistry; - } - - getScopedStore(scopeId = this.props.scope) { - const { storeType } = this.constructor; - const { storeState } = this.getRegistry().getStore(storeType, scopeId); - // instead of returning global bound actions - // we return the ones with the countainer props binding - return { - storeState, - actions: this.state.scopedActions, - }; - } - - deleteScopedStore(prevStoreState, scopeId = this.props.scope) { - const { storeState } = this.getScopedStore(scopeId); - if ( - scopeId != null && - !prevStoreState.listeners().size && - // ensure registry has not already created a new store w/ same scope - prevStoreState === storeState - ) { - const { storeType } = this.constructor; - this.getRegistry().deleteStore(storeType, scopeId); - } - } - - render() { - const { children } = this.props; - return ( - {children} - ); - } -} - export function createContainer( StoreOrOptions = {}, { onInit = noop, onUpdate = noop, onCleanup = noop, displayName = '' } = {} @@ -186,13 +20,6 @@ export function createContainer( 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 @@ -210,7 +37,9 @@ export function createContainer( return createFunctionContainer(StoreOrOptions); } -function useRegistry(scope, isGlobal, { globalRegistry }) { +function useRegistry(scope, isGlobal) { + const { globalRegistry } = useContext(Context); + return useMemo(() => { const isLocal = !scope && !isGlobal; return isLocal ? new StoreRegistry('__local__') : globalRegistry; @@ -228,13 +57,17 @@ function useContainedStore(scope, registry, props, check, override) { propsRef.current = props; const getContainedStore = useCallback( - (Store) => { + (Store, callsiteRetrieve) => { 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 config = { props: () => propsRef.current, contained: check }; + const config = { + props: () => propsRef.current, + contained: check, + retrieveStore: callsiteRetrieve, + }; const { storeState, actions } = registry.getStore(Store, scope, config); const handlers = bindActions( { ...Store.handlers, ...override?.handlers }, @@ -260,15 +93,22 @@ function useContainedStore(scope, registry, props, check, override) { return [containedStores, getContainedStore]; } -function useApi(check, getContainedStore, { globalRegistry, retrieveStore }) { +function useApi(check, getContainedStore) { + const { globalRegistry, retrieveStore } = useContext(Context); + const retrieveRef = useRef(); - retrieveRef.current = (Store) => - check(Store) ? getContainedStore(Store) : retrieveStore(Store); + retrieveRef.current = (Store, callsiteRetrieve) => + check(Store) + ? getContainedStore(Store, callsiteRetrieve) + : retrieveStore(Store, callsiteRetrieve); // This api is "frozen", as changing it will trigger re-render across all consumers // so we link retrieveStore dynamically and manually call notify() on scope change return useMemo( - () => ({ globalRegistry, retrieveStore: (s) => retrieveRef.current(s) }), + () => ({ + globalRegistry, + retrieveStore: (...args) => retrieveRef.current(...args), + }), [globalRegistry] ); } @@ -280,8 +120,7 @@ function createFunctionContainer({ displayName, override } = {}) { : store.containedBy === FunctionContainer; function FunctionContainer({ children, scope, isGlobal, ...restProps }) { - const ctx = useContext(Context); - const registry = useRegistry(scope, isGlobal, ctx); + const registry = useRegistry(scope, isGlobal); const [containedStores, getContainedStore] = useContainedStore( scope, registry, @@ -289,7 +128,7 @@ function createFunctionContainer({ displayName, override } = {}) { check, override ); - const api = useApi(check, getContainedStore, ctx); + const api = useApi(check, getContainedStore); // 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 diff --git a/src/components/hook.js b/src/components/hook.js index 73fe51d..035bce3 100644 --- a/src/components/hook.js +++ b/src/components/hook.js @@ -10,7 +10,7 @@ const DEFAULT_SELECTOR = (state) => state; export function createHook(Store, { selector } = {}) { return function useSweetState(propsArg) { const { retrieveStore } = useContext(Context); - const { storeState, actions } = retrieveStore(Store); + const { storeState, actions } = retrieveStore(Store, retrieveStore); const hasPropsArg = propsArg !== undefined; const propsArgRef = useRef(propsArg); diff --git a/src/context.js b/src/context.js index 85fc707..acb88a3 100644 --- a/src/context.js +++ b/src/context.js @@ -7,7 +7,18 @@ import { defaultRegistry } from './store'; export const Context = React.createContext( { globalRegistry: defaultRegistry, - retrieveStore: (Store) => defaultRegistry.getStore(Store), + retrieveStore: (Store, callsiteRetrieve) => { + const context = { + props: () => ({}), + retrieveStore: callsiteRetrieve, + isContained: () => false, + }; + return defaultRegistry.getStore( + Store, + defaultRegistry.defaultScope, + context + ); + }, }, () => 0 ); diff --git a/src/store/__tests__/bind-actions.test.js b/src/store/__tests__/bind-actions.test.js index 3317913..38b5322 100644 --- a/src/store/__tests__/bind-actions.test.js +++ b/src/store/__tests__/bind-actions.test.js @@ -1,14 +1,15 @@ /* eslint-env jest */ -import { actionsMock, storeStateMock } from '../../__tests__/mocks'; +import { actionsMock, storeStateMock, StoreMock } from '../../__tests__/mocks'; import { bindAction, bindActions } from '../bind-actions'; import defaults from '../../defaults'; jest.mock('../../defaults'); -const createConfigArg = ({ props = {} } = {}) => ({ +const createConfigArg = ({ props = {}, retrieveStore = () => null } = {}) => ({ props: () => props, contained: () => false, + retrieveStore, }); describe('bindAction', () => { @@ -38,6 +39,7 @@ describe('bindAction', () => { decrease: expect.any(Function), }), dispatch: expect.any(Function), + dispatchTo: expect.any(Function), }, containerProps ); @@ -80,6 +82,69 @@ describe('bindAction', () => { expect(storeStateMock.mutator.actionName).toEqual('myAction2.dispatch'); }); + + it('should expose action name to devtools on dispatchTo call', () => { + defaults.devtools = true; + const Store2 = { ...StoreMock, key: 'store2-key' }; + const action2 = + () => + ({ setState }) => + setState(); + const action = + () => + ({ dispatchTo }) => + dispatchTo(Store2, action2()); + + const retrieveStore = () => ({ + storeState: { ...storeStateMock, key: 'store2-key' }, + actions: { ...Store2.actions }, + }); + + const result = bindAction( + storeStateMock, + action, + 'myAction2', + createConfigArg({ retrieveStore }), + actionsMock + ); + result(); + + expect(storeStateMock.mutator.actionName).toEqual('myAction2.dispatchTo'); + }); + + it('should work recursively', () => { + defaults.devtools = true; + const Store2 = { ...StoreMock, key: 'store2-key' }; + const action3 = + () => + ({ setState }) => + setState({}); + const action2 = + () => + ({ dispatch }) => + dispatch(action3()); + const action = + () => + ({ dispatchTo }) => + dispatchTo(Store2, action2()); + const retrieveStore = () => ({ + storeState: { ...storeStateMock, key: 'store2-key' }, + actions: { ...Store2.actions }, + }); + + const result = bindAction( + storeStateMock, + action, + 'myAction2', + createConfigArg({ retrieveStore }), + actionsMock + ); + result(); + + expect(storeStateMock.mutator.actionName).toEqual( + 'myAction2.dispatchTo.dispatch' + ); + }); }); describe('bindActions', () => { diff --git a/src/store/bind-actions.js b/src/store/bind-actions.js index ace3654..5aa9fb4 100644 --- a/src/store/bind-actions.js +++ b/src/store/bind-actions.js @@ -37,6 +37,10 @@ export const bindAction = ( return actions; }, dispatch: (tFn) => callThunk(instance, tFn, `${actionName}.dispatch`), + unstable_dispatchTo: (Store, tFn) => { + const toInstance = config.retrieveStore(Store); + return callThunk(toInstance, tFn, `${actionName}.dispatchTo`); + }, }, config.props() ); diff --git a/src/store/registry.js b/src/store/registry.js index 1314901..3bba990 100644 --- a/src/store/registry.js +++ b/src/store/registry.js @@ -39,7 +39,11 @@ export class StoreRegistry { getStore = ( Store, scopeId = this.defaultScope, - config = { props: () => ({}), contained: () => false } + config = { + props: () => ({}), + contained: () => false, + retrieveStore: () => null, + } ) => { const key = this.generateKey(Store, scopeId); return ( diff --git a/types/index.d.ts b/types/index.d.ts index b5225cb..aee964f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -57,6 +57,13 @@ declare module 'react-sweet-state' { dispatch: >( actionThunk: T ) => ReturnType; + unstable_dispatchTo: < + S extends Store, + T extends Action + >( + store: S, + actionThunk: T + ) => ReturnType; }; type Action<