diff --git a/src/internal/index.ts b/src/internal/index.ts index a1da11f..6ee9ad8 100644 --- a/src/internal/index.ts +++ b/src/internal/index.ts @@ -39,3 +39,4 @@ export { default as circleIndex } from './utils/circle-index'; export { default as Portal, PortalProps } from './portal'; export { useMergeRefs } from './use-merge-refs'; export { useRandomId, useUniqueId } from './use-unique-id'; +export { ReactiveStore, ReadonlyReactiveStore, useReaction, useSelector } from './reactive-store'; diff --git a/src/internal/reactive-store/__tests__/reactive-store.test.tsx b/src/internal/reactive-store/__tests__/reactive-store.test.tsx new file mode 100644 index 0000000..da314ce --- /dev/null +++ b/src/internal/reactive-store/__tests__/reactive-store.test.tsx @@ -0,0 +1,175 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef, useState } from 'react'; +import { render, screen } from '@testing-library/react'; + +import { ReactiveStore, useReaction, useSelector } from '../index'; +import { act } from 'react-dom/test-utils'; + +interface State { + name: string; + values: Record; +} + +describe('ReactiveStore', () => { + let store = new ReactiveStore({ name: '', values: {} }); + + beforeEach(() => { + store = new ReactiveStore({ name: 'Test', values: { A: 1, B: 2 } }); + }); + + function Provider({ + store: customStore, + update, + }: { + store?: ReactiveStore; + update?: (state: State) => State; + }) { + const providerStore = customStore ?? store; + + const renderCounter = useRef(0); + renderCounter.current += 1; + + useReaction( + providerStore, + s => s.name, + (newName, prevName) => { + const div = document.querySelector('[data-testid="reaction-name"]')!; + div.textContent = `${prevName} -> ${newName}`; + } + ); + + return ( +
+
Provider ({renderCounter.current})
+ + + +
+
+ ); + } + + function StoreUpdater({ store, update }: { store: ReactiveStore; update?: (state: State) => State }) { + useState(() => { + if (update) { + store.set(prev => update(prev)); + } + return null; + }); + return null; + } + + function SubscriberName({ store }: { store: ReactiveStore }) { + const value = useSelector(store, s => s.name); + const renderCounter = useRef(0); + renderCounter.current += 1; + return ( +
+ Subscriber name ({renderCounter.current}) {value} +
+ ); + } + + function SubscriberItemsList({ store }: { store: ReactiveStore }) { + const items = useSelector(store, s => s.values); + const itemIds = Object.keys(items); + const renderCounter = useRef(0); + renderCounter.current += 1; + return ( +
+
+ Subscriber items ({renderCounter.current}) {itemIds.join(', ')} +
+ {itemIds.map(itemId => ( +
+ +
+ ))} +
+ ); + } + + function SubscriberItem({ id, store }: { id: string; store: ReactiveStore }) { + const value = useSelector(store, s => s.values[id]); + const renderCounter = useRef(0); + renderCounter.current += 1; + return ( +
+ Subscriber {id} ({renderCounter.current}) {value} +
+ ); + } + + test('initializes state correctly', () => { + render(); + + expect(screen.getByTestId('provider').textContent).toBe('Provider (1)'); + expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (1) Test'); + expect(screen.getByTestId('subscriber-items').textContent).toBe('Subscriber items (1) A, B'); + expect(screen.getByTestId('subscriber-A').textContent).toBe('Subscriber A (1) 1'); + expect(screen.getByTestId('subscriber-B').textContent).toBe('Subscriber B (1) 2'); + }); + + test('handles updates correctly', () => { + render(); + + act(() => store.set(prev => ({ ...prev, name: 'Test', values: { ...prev.values, B: 3, C: 4 } }))); + + expect(screen.getByTestId('provider').textContent).toBe('Provider (1)'); + expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (1) Test'); + expect(screen.getByTestId('subscriber-items').textContent).toBe('Subscriber items (2) A, B, C'); + expect(screen.getByTestId('subscriber-A').textContent).toBe('Subscriber A (2) 1'); + expect(screen.getByTestId('subscriber-B').textContent).toBe('Subscriber B (2) 3'); + expect(screen.getByTestId('subscriber-C').textContent).toBe('Subscriber C (1) 4'); + + act(() => store.set(prev => ({ ...prev, name: 'Updated' }))); + + expect(screen.getByTestId('provider').textContent).toBe('Provider (1)'); + expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (2) Updated'); + expect(screen.getByTestId('subscriber-items').textContent).toBe('Subscriber items (2) A, B, C'); + expect(screen.getByTestId('subscriber-A').textContent).toBe('Subscriber A (2) 1'); + expect(screen.getByTestId('subscriber-B').textContent).toBe('Subscriber B (2) 3'); + expect(screen.getByTestId('subscriber-C').textContent).toBe('Subscriber C (1) 4'); + }); + + test('reacts to updates with useReaction', () => { + render(); + + act(() => store.set(prev => ({ ...prev, name: 'Reaction test' }))); + + expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (2) Reaction test'); + expect(screen.getByTestId('reaction-name').textContent).toBe('Test -> Reaction test'); + }); + + test('unsubscribes listeners on unmount', () => { + const { unmount } = render(); + + expect(store).toEqual(expect.objectContaining({ _listeners: expect.objectContaining({ length: 5 }) })); + + unmount(); + + expect(store).toEqual(expect.objectContaining({ _listeners: expect.objectContaining({ length: 0 }) })); + }); + + test('synchronizes updates done between render and effect', () => { + render( ({ ...state, name: 'Test!' })} />); + + expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (2) Test!'); + }); + + test('reacts to store replacement', () => { + const { rerender } = render(); + + expect(screen.getByTestId('provider').textContent).toBe('Provider (1)'); + expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (1) Test'); + expect(screen.getByTestId('reaction-name').textContent).toBe(''); + + rerender(({ name: 'Other test', values: {} })} />); + + expect(screen.getByTestId('provider').textContent).toBe('Provider (2)'); + expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (3) Other test'); + expect(screen.getByTestId('reaction-name').textContent).toBe('Test -> Other test'); + }); +}); diff --git a/src/internal/reactive-store/index.ts b/src/internal/reactive-store/index.ts new file mode 100644 index 0000000..53b585c --- /dev/null +++ b/src/internal/reactive-store/index.ts @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useRef, useState } from 'react'; + +type Selector = (state: S) => R; +type Listener = (state: S, prevState: S) => void; + +export interface ReadonlyReactiveStore { + get(): S; + subscribe(selector: Selector, listener: Listener): () => void; + unsubscribe(listener: Listener): void; +} + +/** + * A pub/sub state management util that registers listeners by selectors. + * It comes with React utils that subscribe to state changes and trigger effects or React state updates. + * + * For simple states, a store can be defined as `ReactiveStore`. For more complex states, + * it is recommended to create a custom class extending ReactiveStore and providing custom setters, + * for example: + * class TableStore extends ReactiveStore { + * setVisibleColumns(visibleColumns) { + * this.set((prev) => ({ ...prev, visibleColumns })); + * } + * // ... + * } + * + * The store instance is usually created once when the component is mounted, which can be achieved with React's + * useRef or useMemo utils. To make the store aware of component's properties it is enough to assign them on + * every render, unless a state recomputation is required (in which case a useEffect is needed). + * const store = useRef(new TableStore()).current; + * store.totalColumns = props.totalColumns; + * + * As long as every selector un-subscribes on un-mount (which is the case when `useSelector()` helper is used), + * there is no need to do any cleanup on the store itself. + */ +export class ReactiveStore implements ReadonlyReactiveStore { + private _state: S; + private _listeners: [Selector, Listener][] = []; + + constructor(state: S) { + this._state = state; + } + + public get(): S { + return this._state; + } + + public set(cb: (state: S) => S): void { + const prevState = this._state; + const newState = cb(prevState); + + this._state = newState; + + for (const [selector, listener] of this._listeners) { + if (selector(prevState) !== selector(newState)) { + listener(newState, prevState); + } + } + } + + public subscribe(selector: Selector, listener: Listener): () => void { + this._listeners.push([selector, listener]); + return () => this.unsubscribe(listener); + } + + public unsubscribe(listener: Listener): void { + this._listeners = this._listeners.filter(([, storedListener]) => storedListener !== listener); + } +} + +/** + * Triggers an effect every time the selected store state changes. + */ +export function useReaction( + store: ReadonlyReactiveStore, + selector: Selector, + effect: Listener +): void { + const prevStore = useRef(store); + useEffect( + () => { + if (prevStore.current !== store) { + effect(selector(store.get()), selector(prevStore.current.get())); + prevStore.current = store; + } + const unsubscribe = store.subscribe(selector, (next, prev) => effect(selector(next), selector(prev))); + return unsubscribe; + }, + // Ignoring selector and effect as they are expected to stay constant. + // eslint-disable-next-line react-hooks/exhaustive-deps + [store] + ); +} + +/** + * Creates React state that updates every time the selected store state changes. + */ +export function useSelector(store: ReadonlyReactiveStore, selector: Selector): R { + const [state, setState] = useState(selector(store.get())); + + // We create subscription synchronously during the first render cycle to ensure the store updates that + // happen after the first render but before the first effect are not lost. + const unsubscribeRef = useRef(store.subscribe(selector, newState => setState(selector(newState)))); + // When the component is un-mounted or the store reference changes, the old subscription is cancelled + // (and the new subscription is created for the new store instance). + const prevStore = useRef(store); + useEffect(() => { + if (prevStore.current !== store) { + setState(selector(store.get())); + unsubscribeRef.current = store.subscribe(selector, newState => setState(selector(newState))); + prevStore.current = store; + } + return () => unsubscribeRef.current(); + // Ignoring selector as it is expected to stay constant. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store]); + + return state; +}