おすすめ自作 React hooks 集 3
公式ドキュメントへのリンク
RxJS hooks
hooks のおかげで React で RxJS を使うのがだいぶやりやすくなりました。
eventやpropsをObservable (≒ stream) に変換して扱うことで複雑な状態管理を綺麗に扱いやすくなります(と私は思っています)。
そもそもRxJSを使うとどう嬉しいのか、のような部分は以前記事を書いてみたのでよろしければご覧下さい。→ https://qiita.com/pikohideaki/items/292ab134397f4959e66b
definitions of hooks
// rxjs-hooks.ts
import { useCallback, useEffect, useMemo, useState } from "react";
import { BehaviorSubject, Observable, Subject } from "rxjs";
import { shareReplay, startWith, distinctUntilChanged } from "rxjs/operators";
export const useStream = <T>(stream$: Observable<T>): Observable<T> => {
const ref = useRef(stream$.pipe(shareReplay(1)));
return ref.current;
};
export const useDataStream = <T>(
initialValue: T,
stream$: Observable<T>
): Observable<T> =>
useStream<T>(stream$.pipe(startWith(initialValue), skipUnchangedImmutable()));
export const useStreamEffect = <T>(
stream$: Observable<T>,
subscriptionFn: (v: T) => void
): void => {
const ref = useRef({ stream$, subscriptionFn });
useEffect(() => {
const s = ref.current.stream$.subscribe(ref.current.subscriptionFn);
return () => {
s.unsubscribe();
};
}, []);
};
interface UseStreamValueType {
<T>(stream$: Observable<T>): T | undefined;
<T>(stream$: Observable<T>, initialValue: T): T;
}
// Wraps the value with an object to avoid setState's update behavior when T is function type.
export const useStreamValue: UseStreamValueType = <T>(
stream$: Observable<T>,
initialValue?: T
) => {
const [state, setState] = useState<{ value: T | undefined }>({
value: initialValue
});
useStreamEffect(stream$, value => setState({ value }));
return state.value ?? initialValue;
};
export const useStateAsStream = <T>(
initialValue: T
): [Observable<T>, (v: T) => void] => {
const src$ = useRef(new BehaviorSubject<T>(initialValue));
const setter = useCallback((v: T) => {
src$.current.next(v);
}, []);
const value$ = useStream<T>(src$.current.asObservable());
return [value$, setter];
};
export const useValueAsStream = <T>(input: T) => {
const [value$, setValue] = useStateAsStream<T>(input);
useEffect(() => {
setValue(input);
}, [input, setValue]);
return value$;
};
説明
- useStream : FC内でObservableを使うときは
useMemo
で囲わないとObservable objectが毎回更新されて変なことになります。shareReplay(1)
でhot observable に変換しているのは好みです(cold observableだと起きやすいバグがいくつかあるので私はhotに統一しています)。すべてのObservableに共通で施したいオペレータがあればここに書いておけば良いと思います。 - useDataStream : データを表すstream用の
useStream
です。常に初期値を持ち、発火値が変化していないならば無視するようにしています。ボタンクリックイベントをstreamにするなど値ではなく発火が意味を持つ場合はuseStream
を、それ以外の場合はuseDataStream
を使っています。 - useStreamEffect : streamをlisten (=subscribe) します。
- useStreamValue : streamの中身の値を取り出します。viewに使うもののみこのメソッドで取り出します。
- useStateAsStream : useStateのstream版です。
- useValueAsStream : 通常の変数の値をstreamに変換します。propsをstreamに変換したいときなどに使います。
使い方
以前作った カタン(ボードゲーム)用ダイスアプリ のロジック部分。(時間とやる気があったらもっとシンプルな例に差し替えたい)
主に opacity$
の部分のために Rx を使用した。(CSSアニメーション力が低かったのでJS側制御で書いてしまった…。)
import * as I from 'immutable'
import React, { memo } from 'react'
import { interval, merge } from 'rxjs'
import { map, scan, switchMapTo, take } from 'rxjs/operators'
import { historyReducer } from '../functions/history-reducer'
import { historyToSumCount } from '../functions/history-to-sum-count'
import { HistoryState } from '../type/history'
import {
useDataStream,
useEventAsStream,
useStreamValue
} from '../utils/rxjs-hooks'
import { MainView } from './main-view'
const sumCountInitial = I.Repeat(0, 12 - 2 + 1).toList()
export const Main = memo(() => {
/* event streams */
const [rollDices$, rollDices] = useEventAsStream('roll-dices' as const)
const [undo$, undo] = useEventAsStream('undo' as const)
const [redo$, redo] = useEventAsStream('redo' as const)
/* data streams */
const history$ = useDataStream(
HistoryState(),
merge(rollDices$, undo$, redo$).pipe(scan(historyReducer, HistoryState()))
)
const undoable$ = useDataStream(false, history$.pipe(map(h => h.index > -1)))
const redoable$ = useDataStream(
false,
history$.pipe(map(h => h.index < h.history.size - 1))
)
const diceValues$ = useDataStream<[number, number]>(
[0, 0],
history$.pipe(
map(histState => histState.history.get(histState.index, [0, 0]))
)
)
const sumCount$ = useDataStream<I.List<number>>(
sumCountInitial,
history$.pipe(map(historyToSumCount))
)
const opacity$ = useDataStream<number>(
0,
rollDices$.pipe(
switchMapTo(
interval(50).pipe(
take(11),
map(i => (10 - i) / 10)
)
)
)
)
/* extract values */
const [dice1, dice2] = useStreamValue(diceValues$, [0, 0] as [number, number])
const sumCount = useStreamValue(sumCount$, sumCountInitial)
const undoable = useStreamValue(undoable$, false)
const redoable = useStreamValue(redoable$, false)
const opacity = useStreamValue(opacity$, 0)
return (
<MainView
diceValue1={dice1}
diceValue2={dice2}
sumCount={sumCount}
opacity={opacity}
rollDices={rollDices}
undo={undo}
redo={redo}
undoable={undoable}
redoable={redoable}
/>
)
})
// history-to-sum-count.ts
import * as I from 'immutable'
import { THistoryState } from '../type/history'
export const historyToSumCount = (history: THistoryState) => {
const count = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
const historyFiltered = history.history.take(history.index + 1)
historyFiltered.forEach(([a, b]) => {
count[a + b - 2] += 1
})
return I.List<number>(count)
}
// history-reducer.ts
import { THistoryState } from '../type/history'
import { rollTwoDices } from './roll-dice'
export const historyReducer = (
state: THistoryState,
action: 'undo' | 'redo' | 'roll-dices'
) =>
state.withMutations(st => {
const size = st.history.size
const currIdx = st.index
st.update('index', idx => {
switch (action) {
case 'undo':
return Math.max(-1, idx - 1)
case 'redo':
return Math.min(size - 1, idx + 1)
case 'roll-dices':
return idx + 1
}
})
st.update('history', history => {
switch (action) {
case 'undo':
case 'redo':
return st.history
case 'roll-dices':
const diceValues = rollTwoDices()
return history.take(currIdx + 1).push(diceValues)
}
})
})
おすすめ自作 React hooks 集は随時追加予定です。