🤝

ReduxからJotaiへのマイグレーション体験(超入門)

2022/02/19に公開

はじめに

Reactの状態管理ライブラリであるReduxのコードを、Jotaiに書き換えて紹介している記事はなさそうだったので、簡単なカウンターアプリを題材にします。
Redux公式ドキュメントで紹介されているサンプルコード付きのものがあったのでそれを対象にします。
Redux Essentials, Part 2: Redux App Structure の The Counter Example App

https://codesandbox.io/s/github/reduxjs/redux-essentials-counter-example/tree/master/?from-embed

今回はこちらをJotaiで書き換えたいと思います。

まずはReduxのコードを確認し、それに対応するJotaiでの書き方を確認します。
JotaiはReduxのようにボイラープレートな書き方にはならず、自由です。体験、超入門とタイトル付けしましたが、書き方は一例として見てください。細かなReduxやJotaiの説明は省きます。記事の最後にJotaiに関する記事のURLを載せてますので、参考にして頂ければです。

対象読者

  • Jotai未経験のすべてのReactユーザー
  • 現役Reduxユーザー〜最近使っていない人まで
  • Recoil, Jotaiに興味のある人

ReduxのThe Counter Example Appを確認

Storeの定義と設定

Provider設定

Providerへstoreを設定していますね。

src/index.js
...(省略)...
import { Provider } from 'react-redux';
import store from './app/store';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
...(省略)...

Storeの定義

先程のstoreの中身です。今回はcounterですね。Redux Tool Kitが使われています。

src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export default configureStore({
  reducer: {
    counter: counterReducer,
  },
});

counter state と update関数 の定義

storeへ定義したcounterの中身です。コード内にコメントとしていくつかの説明をします。

src/features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'

// counterのstateと3つのreducer。Redux Tool KitではImmerが採用されています。
export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => { // ユーザーが入力した数字で加算
      state.value += action.payload
    },
  },
})

// 各reducerをexport
export const { increment, decrement, incrementByAmount } = counterSlice.actions

export const incrementAsync = (amount) => (dispatch) => {
  setTimeout(() => {
    dispatch(incrementByAmount(amount))
  }, 1000)
}

// セレクタ関数。counterのstateであるcountを指定
export const selectCount = (state) => state.counter.value

export default counterSlice.reducer

Counterコンポーネント

見やすさのためにcss部分とaria-labelの記述を削っています。
特に説明は不要でしょうか。

src/features/counter/Counter.js
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
  decrement,
  increment,
  incrementByAmount,
  incrementAsync,
  selectCount,
} from './counterSlice';

export function Counter() {
  const count = useSelector(selectCount);
  const dispatch = useDispatch();
  const [incrementAmount, setIncrementAmount] = useState('2');

  return (
    <div>
      <div>
        <button onClick={() => dispatch(increment())}>
          +
        </button>
        <span>{count}</span>
        <button onClick={() => dispatch(decrement())}>
          -
        </button>
      </div>
      <div>
        <input
          value={incrementAmount}
          onChange={e => setIncrementAmount(e.target.value)}
        />
        <button
          onClick={() =>
            dispatch(incrementByAmount(Number(incrementAmount) || 0))
          }
        >
          Add Amount
        </button>
        <button
          onClick={() => dispatch(incrementAsync(Number(incrementAmount) || 0))}
        >
          Add Async
        </button>
      </div>
    </div>
  );
}

以上で主要なコードは終了です。以下が実際に動作するプレビューです。

Jotaiへの書き換え

まずは書き換え結果から見てみます。問題なさそうです。

Storeの定義と設定

Provider設定

JotaiはProviderが無くても動作します。今回は不要なので削ります。

src/index.js
...(省略)...
ReactDOM.render(
  <App />,
  document.getElementById('root')
);
...(省略)...

Storeの定義

JotaiはStoreを一つ一つのatomとして持ちます。なのでReduxのような巨大な1つのStoreにまとめる必要はありません。

counter state と update関数 の定義

counterSlice.jsというファイル名は不適切かもしれませんが、書き換えということでファイル名はこのままに。

今回は、counterなのでカウントの値を1つ持つatomを定義します。

各reducerをwrite-only atomを用いて表現しました。get(countAtom)でその時のcountAtomの中身を取得して加減算してsetしています。
write関数は第3引数に任意の値を受ける事ができます。例えば、incrementByAmountAtomではamountを受けるようにしてそれで加算しています。使う場面はCounterコンポーネントを見てください。
セレクタ関数は不要です。

お気づきのように、全てatomで表現できます。

src/features/counter/counterSlice.js
import { atom } from "jotai";

export const countAtom = atom(0); // storeであり、counterSliceのinitialState.value部分と同義
export const incrementAtom = atom(null, (get, set) =>
  set(countAtom, get(countAtom) + 1)
);
export const decrementAtom = atom(null, (get, set) =>
  set(countAtom, get(countAtom) - 1)
);
export const incrementByAmountAtom = atom(null, (get, set, amount) =>
  set(countAtom, get(countAtom) + amount)
);

export const incrementAsyncAtom = atom(null, (get, set, amount) => {
  setTimeout(() => {
    set(countAtom, get(countAtom) + amount);
  }, 1000);
});

Counterコンポーネント

定義したatomをJotai APIのhooksを用いてコンポーネントで使えるようにします。
v1.6.0から、値のみを得るuseAtomValue、update関数のみを得るuseSetAtom(旧useUpdateAtom)が提供されたのでそれらを使いました。

const dispatch = useDispatch();のようなものは不要です。
完全な一致はしませんが、書き換え後の対応関係をいくつか紹介すると、以下のような感じになります。

const count = useSelector(selectCount);const count = useAtomValue(countAtom);

const dispatch = useDispatch();
dispatch(increment())const increment = useSetAtom(incrementAtom);
increment()

Counterコンポーネントの書き換え後は以下です。

src/features/counter/Counter.js
import React, { useState } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import {
  countAtom,
  decrementAtom,
  incrementAtom,
  incrementByAmountAtom,
  incrementAsyncAtom
} from "./counterSlice";

export function Counter() {
  const count = useAtomValue(countAtom);

  const decrement = useSetAtom(decrementAtom);
  const increment = useSetAtom(incrementAtom);
  const incrementByAmount = useSetAtom(incrementByAmountAtom);
  const incrementAsync = useSetAtom(incrementAsyncAtom);

  const [incrementAmount, setIncrementAmount] = useState("2");

  return (
    <div>
      <div>
        <button onClick={() => increment()}>
          +
        </button>
        <span>{count}</span>
        <button onClick={() => decrement()}>
          -
        </button>
      </div>
      <div>
        <input
          value={incrementAmount}
          onChange={(e) => setIncrementAmount(e.target.value)}
        />
        <button
	  {/* atom(null, (get, set, amount) => ...) のamountがNumber(incrementAmount) || 0 */}
          onClick={() => incrementByAmount(Number(incrementAmount) || 0)}
        >
          Add Amount
        </button>
        <button
          onClick={() => incrementAsync(Number(incrementAmount) || 0)}
        >
          Add Async
        </button>
      </div>
    </div>
  );
}

おわりに

いかがでしたでしょうか。冒頭にも述べましたが、Jotaiは自由度の高い使い方ができるためReduxのように型にはまった記述は無くなります。Storeもatomベースで、アプリ全体のsingle sourceを気にせず必要な時に定義し使うことが出来ます。

JotaiはReact.useState()のインターフェースと同じ様に使えるuseAtom()を提供しています。なので、const [count, setCount] = useAtom(countAtom)とも書けます。
今回のcounterを例にすると、incrementはsetCount((state) => state + 1)、decrementはsetCount((state) => state - 1)、incrementByAmountはsetCount((state) => state + Number(incrementAmount) || 0)と書くことも出来ます。

今回、Reduxのaction(reducer)部分は、Jotaiのwrite-only atomで表現してみました。プロジェクト内でstateの更新処理を明確に分離しておきたい時にはこの書き方をルールとしても良いかもしれません。

Redux Essentialsにはさらに、social media feed appと題してカウンターアプリより実践的な題材を扱っています。
今後はこれを対象にJotaiでの書き換えを試してみようと思います。

アペンディクス

https://jotaifriends.dev/knowledge-contents

最新の記事はこちら。
https://zenn.dev/tell_y/articles/d714f9c16c1d3a

おまけ

Twitterアカウント、フォロー頂けると嬉しいです。
https://twitter.com/jotaifriends/status/1494161918260805632

Jotai Friends

Discussion