7
6

More than 3 years have passed since last update.

おすすめ自作 React hooks集3 (RxJS hooks)

Last updated at Posted at 2019-09-01

おすすめ自作 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 集は随時追加予定です。

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6