React 連携
SynState は、シームレスな React 連携のためのコンパニオンパッケージ synstate-react-hooks を提供しています。
インストール
Section titled “インストール”npm add synstate-react-hooks基本的な使い方
Section titled “基本的な使い方”synstate-react-hooks の createState は、生の Observable の代わりに React hook を返します:
import type * as React from 'react';import { createState } from 'synstate-react-hooks';
const [useUserState, setUserState] = createState({ name: '', email: '',});
const UserProfile = (): React.JSX.Element => { const user = useUserState();
return ( <div> <p>{`Name: ${user.name}`}</p> <button onClick={() => { setUserState({ name: 'Alice', email: 'alice@example.com', }); }} > {'Set User'} </button> </div> );};これは synstate-react-hooks を使用しない以下のコードと同等です:
import * as React from 'react';import { createState } from 'synstate';
const [userState, setUserState] = createState({ name: '', email: '',});
// Defined once: `useSyncExternalStore` subscribes again whenever `subscribe` is// a new function.const subscribeUser = (onStoreChange: () => void): (() => void) => { const { unsubscribe } = userState.subscribe(onStoreChange);
return unsubscribe;};
const getUser = (): Readonly<{ name: string; email: string }> => userState.getSnapshot().value;
const UserProfile = (): React.JSX.Element => { // `getUser` doubles as `getServerSnapshot`, which server rendering and // hydration require. const user = React.useSyncExternalStore(subscribeUser, getUser, getUser);
return ( <div> <p>{`Name: ${user.name}`}</p> <button onClick={() => { setUserState({ name: 'Alice', email: 'alice@example.com', }); }} > {'Set User'} </button> </div> );};追加ユーティリティ:3番目の要素
Section titled “追加ユーティリティ:3番目の要素”synstate-react-hooks の createState も、コアパッケージと同様に3要素のタプルを返します。3番目の要素には createState 詳説 で説明されているものと同じユーティリティ(updateState、resetState、getSnapshot、initialState)に加え、コアライブラリの Observable である state が含まれています:
import type * as React from 'react';import { createState } from 'synstate-react-hooks';
// The third element provides additional utilities and the underlying Observable.const [ useCount, setCount, { state, updateState: updateCount, resetState: resetCount, getSnapshot: getCountSnapshot, },] = createState(0);
const increment = (): void => { updateCount((n) => n + 1);};
const Counter = (): React.JSX.Element => { const count = useCount();
return ( <div> <p>{`Count: ${count}`}</p> <button onClick={increment}>{'Increment'}</button> <button onClick={resetCount}>{'Reset'}</button> </div> );};
// `state` is the same InitializedObservable<number> that the core synstate// package's createState returns as its first element.// You can use it with pipe, combine, subscribe, etc.state.subscribe((value) => { console.log('count changed:', value);});
// Read the current value synchronously (outside of React rendering)console.log('current count:', getCountSnapshot());| プロパティ | 型 | 説明 |
|---|---|---|
state | InitializedObservable<S> | コアの synstate パッケージの createState が第1要素として返すのと同じ Observable。pipe、combine、subscribe などと組み合わせて使用可能 |
updateState | (updateFn: (prev: S) => S) => S | 前の値を受け取る関数で状態を更新(詳細は createState 詳説 を参照) |
resetState | () => S | 状態を初期値にリセット |
getSnapshot | () => S | 購読せずに現在の値を同期的に読み取る |
initialState | S | createState に渡された初期値 |
派生 Observable の購読: useObservableValue
Section titled “派生 Observable の購読: useObservableValue”3番目の要素の state は通常の Observable であり、pipe、map、combine などで自由に変換できます。派生した Observable を React コンポーネントで購読するには useObservableValue を使用します:
import type * as React from 'react';import { map } from 'synstate';import { createState, useObservableValue } from 'synstate-react-hooks';
const [useCount, , { state: count$ }] = createState(0);
// Derive a new Observable using pipe + mapconst doubled$ = count$.pipe(map((n) => n * 2));
const message$ = count$.pipe( map((n) => (n === 0 ? 'Click to start' : `Count is ${n}`)),);
const CountDisplay = (): React.JSX.Element => { const count = useCount(); // Equivalent to using useObservableValue(count$)
// Subscribe to derived Observables with useObservableValue const doubled = useObservableValue(doubled$);
const message = useObservableValue(message$);
return ( <div> <p>{`Count: ${count}, Doubled: ${doubled}`}</p> <p>{message}</p> </div> );};useObservableValue は createState、pipe、combine など、SynState のあらゆるオペレーターから得られる Observable に対して動作します。Observable を購読し、現在の値を React state として返します。値が変化するとコンポーネントが再レンダリングされます。
初期値を持たない Observable のためのフォールバック
Section titled “初期値を持たない Observable のためのフォールバック”useObservableValue はオーバーロードされており、ソースに初期値があるかどうかに応じて戻り値の型が変わります:
| シグネチャ | 戻り値の型 |
|---|---|
useObservableValue<A>(obs: InitializedObservable<A>) | A |
useObservableValue<A>(obs: Observable<A>) | A | undefined |
useObservableValue<A, B = A>(obs: Observable<A>, initialValue: B) | A | B |
第二引数はソースがまだ値を発行していない場合に使われるフォールバック値です(内部的に Optional.unwrapOr で適用されます):
import type * as React from 'react';import type { Observable } from 'synstate';import { useObservableValue } from 'synstate-react-hooks';
// Observable<string> that may not have emitted yet.declare const userName$: Observable<string>;
const Greeting = (): React.JSX.Element => { const userName = useObservableValue(userName$, 'Guest'); // string
return <p>{`Hello, ${userName}`}</p>;};createState から得られる Observable や、初期値を保持する pipe チェーンから得られる Observable は InitializedObservable<T> であるため、フォールバックは不要です。
マウント中の副作用:useObservableEffect
Section titled “マウント中の副作用:useObservableEffect”useObservableEffect(observable$, fn) は、コンポーネントのマウント時に fn を購読し、アンマウント時に購読を解除します。クエリが変わったら先頭までスクロールするといった、コンポーネントがマウントされている間だけ実行したい、描画内容を変えない副作用に使います。fn は現在値があればそれですぐに呼ばれ、その後は値が発行されるたびに呼ばれます。戻り値はなく、コンポーネントを再レンダリングしません:
import type * as React from 'react';import { createState, useObservableEffect } from 'synstate-react-hooks';
// Written by the search boxexport const [, setQuery, { state: query$ }] = createState('');
// Written by the code that fetches the resultsexport const [useResults, setResults] = createState<readonly string[]>([]);
const ResultList = (): React.JSX.Element => { const results = useResults();
// Called on mount with the current query, then on every change, until // unmount: the page starts at the top and goes back there for a new query useObservableEffect(query$, () => { window.scrollTo({ top: 0 }); });
return ( <ul> {results.map((r) => ( <li key={r}>{r}</li> ))} </ul> );};各値は最新のレンダリングの fn に渡されるので、fn には現在の props と state が見えます。レンダリングのたびに fn が新しくなっても購読し直しません。別の Observable を渡すと、購読はそちらに移ります。そのため、レンダリングをまたいで同じものであり続ける Observable(コンポーネントの外で作ったもの、または React.useMemo でメモ化したもの)を渡してください。レンダリング中に作った Observable はレンダリングのたびに購読し直され、そのたびに fn が現在の値で呼ばれます。fn の戻り値は無視されます。アンマウント時の後始末は、別の React.useEffect に書いてください。fn は subscriber なので、state に書き込んではいけません。それは伝搬中の書き込みで、ここでも ESLint ルールが報告します。localStorage への保存のように、コンポーネントに属さない副作用はモジュールスコープでの subscribe で書きます。逆に props やコンポーネントの state をグラフに渡すには useValueAsObservable(value) を使います(API リファレンスを参照)。
React v17 以前
Section titled “React v17 以前”React 16.8–17(useSyncExternalStore なし)を使用している場合は、synstate-react-hooks-compat をインストールしてください:
npm add synstate-react-hooks-compatこのパッケージは synstate-react-hooks と同じ API を提供しますが、observable を React 公式の React 16.8・17 向け useSyncExternalStore である use-sync-external-store/shim で読みます。import を変更するだけで使えます:
import { createState } from 'synstate-react-hooks-compat';
const [useUserState, setUserState] = createState({ name: '', email: '',});すべてのフック(createState、createReducer、createBooleanState、useObservableValue、useObservableEffect)が同じように動作します。React 18+ にアップグレードする際は、import を synstate-react-hooks に変更するだけで、他のコード変更は不要です。
手動で実装する場合(追加パッケージ不要)
Section titled “手動で実装する場合(追加パッケージ不要)”コアの synstate パッケージを useState と useEffect で直接使用することもできます:
import * as React from 'react';import { createState } from 'synstate';
// Global state (outside component)const [userState, setUserState, { getSnapshot }] = createState({ name: '', email: '',});
const UserProfile = (): React.JSX.Element => { const [user, setUser] = React.useState(getSnapshot());
React.useEffect(() => { const subscription = userState.subscribe(setUser);
return () => { subscription.unsubscribe(); }; }, []);
return ( <div> <p>{`Name: ${user.name}`}</p> <button onClick={() => { setUserState({ name: 'Alice', email: 'alice@example.com', }); }} > {'Set User'} </button> </div> );};