Skip to content

Commit 1754c94

Browse files
committed
chore: Internal reactive store utils
1 parent 837fb68 commit 1754c94

3 files changed

Lines changed: 297 additions & 0 deletions

File tree

src/internal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ export { default as circleIndex } from './utils/circle-index';
3939
export { default as Portal, PortalProps } from './portal';
4040
export { useMergeRefs } from './use-merge-refs';
4141
export { useRandomId, useUniqueId } from './use-unique-id';
42+
export { ReactiveStore, ReadonlyReactiveStore, useReaction, useSelector } from './reactive-store';
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useRef, useState } from 'react';
5+
import { render, screen } from '@testing-library/react';
6+
7+
import { ReactiveStore, useReaction, useSelector } from '../index';
8+
import { act } from 'react-dom/test-utils';
9+
10+
interface State {
11+
name: string;
12+
values: Record<string, number>;
13+
}
14+
15+
describe('ReactiveStore', () => {
16+
let store = new ReactiveStore<State>({ name: '', values: {} });
17+
18+
beforeEach(() => {
19+
store = new ReactiveStore<State>({ name: 'Test', values: { A: 1, B: 2 } });
20+
});
21+
22+
function Provider({
23+
store: customStore,
24+
update,
25+
}: {
26+
store?: ReactiveStore<State>;
27+
update?: (state: State) => State;
28+
}) {
29+
const providerStore = customStore ?? store;
30+
31+
const renderCounter = useRef(0);
32+
renderCounter.current += 1;
33+
34+
useReaction(
35+
providerStore,
36+
s => s.name,
37+
(newName, prevName) => {
38+
const div = document.querySelector('[data-testid="reaction-name"]')!;
39+
div.textContent = `${prevName} -> ${newName}`;
40+
}
41+
);
42+
43+
return (
44+
<div>
45+
<div data-testid="provider">Provider ({renderCounter.current})</div>
46+
<SubscriberName store={providerStore} />
47+
<SubscriberItemsList store={providerStore} />
48+
<StoreUpdater store={providerStore} update={update} />
49+
<div data-testid="reaction-name"></div>
50+
</div>
51+
);
52+
}
53+
54+
function StoreUpdater({ store, update }: { store: ReactiveStore<State>; update?: (state: State) => State }) {
55+
useState(() => {
56+
if (update) {
57+
store.set(prev => update(prev));
58+
}
59+
return null;
60+
});
61+
return null;
62+
}
63+
64+
function SubscriberName({ store }: { store: ReactiveStore<State> }) {
65+
const value = useSelector(store, s => s.name);
66+
const renderCounter = useRef(0);
67+
renderCounter.current += 1;
68+
return (
69+
<div data-testid="subscriber-name">
70+
Subscriber name ({renderCounter.current}) {value}
71+
</div>
72+
);
73+
}
74+
75+
function SubscriberItemsList({ store }: { store: ReactiveStore<State> }) {
76+
const items = useSelector(store, s => s.values);
77+
const itemIds = Object.keys(items);
78+
const renderCounter = useRef(0);
79+
renderCounter.current += 1;
80+
return (
81+
<div>
82+
<div data-testid="subscriber-items">
83+
Subscriber items ({renderCounter.current}) {itemIds.join(', ')}
84+
</div>
85+
{itemIds.map(itemId => (
86+
<div key={itemId}>
87+
<SubscriberItem id={itemId} store={store} />
88+
</div>
89+
))}
90+
</div>
91+
);
92+
}
93+
94+
function SubscriberItem({ id, store }: { id: string; store: ReactiveStore<State> }) {
95+
const value = useSelector(store, s => s.values[id]);
96+
const renderCounter = useRef(0);
97+
renderCounter.current += 1;
98+
return (
99+
<div data-testid={`subscriber-${id}`}>
100+
Subscriber {id} ({renderCounter.current}) {value}
101+
</div>
102+
);
103+
}
104+
105+
test('initializes state correctly', () => {
106+
render(<Provider />);
107+
108+
expect(screen.getByTestId('provider').textContent).toBe('Provider (1)');
109+
expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (1) Test');
110+
expect(screen.getByTestId('subscriber-items').textContent).toBe('Subscriber items (1) A, B');
111+
expect(screen.getByTestId('subscriber-A').textContent).toBe('Subscriber A (1) 1');
112+
expect(screen.getByTestId('subscriber-B').textContent).toBe('Subscriber B (1) 2');
113+
});
114+
115+
test('handles updates correctly', () => {
116+
render(<Provider />);
117+
118+
act(() => store.set(prev => ({ ...prev, name: 'Test', values: { ...prev.values, B: 3, C: 4 } })));
119+
120+
expect(screen.getByTestId('provider').textContent).toBe('Provider (1)');
121+
expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (1) Test');
122+
expect(screen.getByTestId('subscriber-items').textContent).toBe('Subscriber items (2) A, B, C');
123+
expect(screen.getByTestId('subscriber-A').textContent).toBe('Subscriber A (2) 1');
124+
expect(screen.getByTestId('subscriber-B').textContent).toBe('Subscriber B (2) 3');
125+
expect(screen.getByTestId('subscriber-C').textContent).toBe('Subscriber C (1) 4');
126+
127+
act(() => store.set(prev => ({ ...prev, name: 'Updated' })));
128+
129+
expect(screen.getByTestId('provider').textContent).toBe('Provider (1)');
130+
expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (2) Updated');
131+
expect(screen.getByTestId('subscriber-items').textContent).toBe('Subscriber items (2) A, B, C');
132+
expect(screen.getByTestId('subscriber-A').textContent).toBe('Subscriber A (2) 1');
133+
expect(screen.getByTestId('subscriber-B').textContent).toBe('Subscriber B (2) 3');
134+
expect(screen.getByTestId('subscriber-C').textContent).toBe('Subscriber C (1) 4');
135+
});
136+
137+
test('reacts to updates with useReaction', () => {
138+
render(<Provider />);
139+
140+
act(() => store.set(prev => ({ ...prev, name: 'Reaction test' })));
141+
142+
expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (2) Reaction test');
143+
expect(screen.getByTestId('reaction-name').textContent).toBe('Test -> Reaction test');
144+
});
145+
146+
test('unsubscribes listeners on unmount', () => {
147+
const { unmount } = render(<Provider />);
148+
149+
expect(store).toEqual(expect.objectContaining({ _listeners: expect.objectContaining({ length: 5 }) }));
150+
151+
unmount();
152+
153+
expect(store).toEqual(expect.objectContaining({ _listeners: expect.objectContaining({ length: 0 }) }));
154+
});
155+
156+
test('synchronizes updates done between render and effect', () => {
157+
render(<Provider update={state => ({ ...state, name: 'Test!' })} />);
158+
159+
expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (2) Test!');
160+
});
161+
162+
test('reacts to store replacement', () => {
163+
const { rerender } = render(<Provider />);
164+
165+
expect(screen.getByTestId('provider').textContent).toBe('Provider (1)');
166+
expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (1) Test');
167+
expect(screen.getByTestId('reaction-name').textContent).toBe('');
168+
169+
rerender(<Provider store={new ReactiveStore<State>({ name: 'Other test', values: {} })} />);
170+
171+
expect(screen.getByTestId('provider').textContent).toBe('Provider (2)');
172+
expect(screen.getByTestId('subscriber-name').textContent).toBe('Subscriber name (3) Other test');
173+
expect(screen.getByTestId('reaction-name').textContent).toBe('Test -> Other test');
174+
});
175+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useEffect, useRef, useState } from 'react';
5+
6+
type Selector<S, R> = (state: S) => R;
7+
type Listener<S> = (state: S, prevState: S) => void;
8+
9+
export interface ReadonlyReactiveStore<S> {
10+
get(): S;
11+
subscribe<R>(selector: Selector<S, R>, listener: Listener<S>): () => void;
12+
unsubscribe(listener: Listener<S>): void;
13+
}
14+
15+
/**
16+
* A pub/sub state management util that registers listeners by selectors.
17+
* It comes with React utils that subscribe to state changes and trigger effects or React state updates.
18+
*
19+
* For simple states, a store can be defined as `ReactiveStore<StateType>`. For more complex states,
20+
* it is recommended to create a custom class extending ReactiveStore and providing custom setters,
21+
* for example:
22+
* class TableStore extends ReactiveStore<TableState> {
23+
* setVisibleColumns(visibleColumns) {
24+
* this.set((prev) => ({ ...prev, visibleColumns }));
25+
* }
26+
* // ...
27+
* }
28+
*
29+
* The store instance is usually created once when the component is mounted, which can be achieved with React's
30+
* useRef or useMemo utils. To make the store aware of component's properties it is enough to assign them on
31+
* every render, unless a state recomputation is required (in which case a useEffect is needed).
32+
* const store = useRef(new TableStore()).current;
33+
* store.totalColumns = props.totalColumns;
34+
*
35+
* As long as every selector un-subscribes on un-mount (which is the case when `useSelector()` helper is used),
36+
* there is no need to do any cleanup on the store itself.
37+
*/
38+
export class ReactiveStore<S> implements ReadonlyReactiveStore<S> {
39+
private _state: S;
40+
private _listeners: [Selector<S, unknown>, Listener<S>][] = [];
41+
42+
constructor(state: S) {
43+
this._state = state;
44+
}
45+
46+
public get(): S {
47+
return this._state;
48+
}
49+
50+
public set(cb: (state: S) => S): void {
51+
const prevState = this._state;
52+
const newState = cb(prevState);
53+
54+
this._state = newState;
55+
56+
for (const [selector, listener] of this._listeners) {
57+
if (selector(prevState) !== selector(newState)) {
58+
listener(newState, prevState);
59+
}
60+
}
61+
}
62+
63+
public subscribe<R>(selector: Selector<S, R>, listener: Listener<S>): () => void {
64+
this._listeners.push([selector, listener]);
65+
return () => this.unsubscribe(listener);
66+
}
67+
68+
public unsubscribe(listener: Listener<S>): void {
69+
this._listeners = this._listeners.filter(([, storedListener]) => storedListener !== listener);
70+
}
71+
}
72+
73+
/**
74+
* Triggers an effect every time the selected store state changes.
75+
*/
76+
export function useReaction<S, R>(
77+
store: ReadonlyReactiveStore<S>,
78+
selector: Selector<S, R>,
79+
effect: Listener<R>
80+
): void {
81+
const prevStore = useRef(store);
82+
useEffect(
83+
() => {
84+
if (prevStore.current !== store) {
85+
effect(selector(store.get()), selector(prevStore.current.get()));
86+
prevStore.current = store;
87+
}
88+
const unsubscribe = store.subscribe(selector, (next, prev) => effect(selector(next), selector(prev)));
89+
return unsubscribe;
90+
},
91+
// Ignoring selector and effect as they are expected to stay constant.
92+
// eslint-disable-next-line react-hooks/exhaustive-deps
93+
[store]
94+
);
95+
}
96+
97+
/**
98+
* Creates React state that updates every time the selected store state changes.
99+
*/
100+
export function useSelector<S, R>(store: ReadonlyReactiveStore<S>, selector: Selector<S, R>): R {
101+
const [state, setState] = useState<R>(selector(store.get()));
102+
103+
// We create subscription synchronously during the first render cycle to ensure the store updates that
104+
// happen after the first render but before the first effect are not lost.
105+
const unsubscribeRef = useRef(store.subscribe(selector, newState => setState(selector(newState))));
106+
// When the component is un-mounted or the store reference changes, the old subscription is cancelled
107+
// (and the new subscription is created for the new store instance).
108+
const prevStore = useRef(store);
109+
useEffect(() => {
110+
if (prevStore.current !== store) {
111+
setState(selector(store.get()));
112+
unsubscribeRef.current = store.subscribe(selector, newState => setState(selector(newState)));
113+
prevStore.current = store;
114+
}
115+
return () => unsubscribeRef.current();
116+
// Ignoring selector as it is expected to stay constant.
117+
// eslint-disable-next-line react-hooks/exhaustive-deps
118+
}, [store]);
119+
120+
return state;
121+
}

0 commit comments

Comments
 (0)