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

# おすすめ自作 React hooks 集 3


* [おすすめ自作 React hooks集1](https://qiita.com/pikohideaki/items/1464e13020b3777607c7)
* [おすすめ自作 React hooks集2](https://qiita.com/pikohideaki/items/4238dd17818e58c33799)
* [おすすめ自作 React hooks集4](https://qiita.com/pikohideaki/items/4cbc3cf403fd110412ae)


## 公式ドキュメントへのリンク

- [React hooks API](https://ja.reactjs.org/docs/hooks-intro.html)
- [カスタムフックの作成](https://ja.reactjs.org/docs/hooks-custom.html)

## RxJS hooks

hooks のおかげで React で RxJS を使うのがだいぶやりやすくなりました。

eventやpropsをObservable (≒ stream) に変換して扱うことで複雑な状態管理を綺麗に扱いやすくなります（と私は思っています）。

そもそもRxJSを使うとどう嬉しいのか、のような部分は以前記事を書いてみたのでよろしければご覧下さい。→ https://qiita.com/pikohideaki/items/292ab134397f4959e66b

## definitions of hooks

```ts
// 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に変換したいときなどに使います。

## 使い方

以前作った [カタン（ボードゲーム）用ダイスアプリ](https://catan-dice.netlify.com/) のロジック部分。（時間とやる気があったらもっとシンプルな例に差し替えたい）

主に `opacity$` の部分のために Rx を使用した。（CSSアニメーション力が低かったのでJS側制御で書いてしまった…。）

```tsx
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}
    />
  )
})
```


```ts
// 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)
}
```

```ts
// 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 集は随時追加予定です。