Redux Toolkit また(自分が)忘れないための基本まとめ

寝たら忘れるメモリの僕
2021.12.15

はじめに

おはようございます、もきゅりんです。

大分前に、Redux Toolkit (以後 RTK )を見様見真似で触っていたのですが、改めて最近使おうとしたら、ほとんど記憶から失われていました。

自分の脳みそは、普段あまり使わない記憶をあっという間に風化させてしまいます。(普段フロントエンドを扱うことはほぼないため)

そんなこともあり、きっとまたしばらく手つかずにすると記憶から風化する、と予想されたので、未来の自分のために、今の自分がまとめておこうと考えました。

そして、もしかしたら、自分のように、大分前に RTK 触ったけど、何だか覚えていない、、って方や RTK ってどんなものなの?って興味ある人には、もしかしたらお役に立つかもしれない、ということでブログにしておきました。

RTK の導入の仕方は、Getting Started や、TypeScript Quick Start を見ると、割りとスムーズです。

では、何が記憶から薄れるのか?

  • (個人的に感じる)似たような用語
  • それらの関係

です。

なので、関係図を眺め、サンプルのコードと照らし合わせて思い出すように、未来の自分へ促します。

なお、この記憶喚起するブログには RTK Query は入っていません。

関係図を眺める

とりあえず、これを眺めてどんなものか思い出しましょう。

RTK_image

お分かり頂けただろうか?

わざわざ下図と色を合わせて作図しました。

AsyncThunk_image

出典: Redux Essentials, Part 5: Async Logic and Data Fetching | Redux

この図は以下のようなフローを示しています。

  1. UI からのイベント発火
  2. dispatch から 指定の action を実行
  3. store は action と現 state から reducer を実行して新しく state を生成

state の値を selector を用いて UI に反映させます。

コードと見比べる

図と併せて、図に描かれた概念を上から順に Redux Essentials のコードを使って確認しましょう。

なんでこのチュートリアル、TypeScript で書いてないんだろう、と思って、Redux Essentials と Redux Fundamentals の両方、エラーが出ない程度の最低限の TypeScript にしました。

Redux + TypeScript template で開始すると、State とか useSelector、useDispatch Hooks とかも型定義してくれていて導入がスムーズです。

npx create-react-app my-app --template redux-typescript

Store

Reducer が Store に集約されています。

import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
import notificationsReducer from '../features/notifications/notificationsSlice'

export const store = configureStore({
  reducer: {
    posts: postsReducer,
    users: usersReducer,
    notifications: notificationsReducer,
  },
});

各 Reducer は 各 State と それに影響を与える Action(Dispatch) を管理する概念です。

アプリに必要となる State それぞれで Reducer を管理しますが、 State をどうするかはしっかり考えるべきポイントです。

Designing the State Values でも、下記のように述べられています。

ReactとReduxのコア原則の1つは、UIが State に基づいている必要があるということです。
したがって、アプリケーションを設計するための1つのアプローチは、最初にアプリケーションがどのように機能するために必要なすべての State を考えることです。
また、State 内の値をできるだけ少なくして UI を設計し、追跡および更新する必要のあるデータが少なくなるようにすることもお勧めします。 (邦訳)

そして、index.tsx で store が App 全体を Wrap しています。

import { store } from './app/store';
import { Provider } from 'react-redux';
import * as serviceWorker from './serviceWorker';

import { fetchUsers } from './features/users/usersSlice';

import { worker } from './api/server';

// Start our mock API server
worker.start({ onUnhandledRequest: 'bypass' });

store.dispatch(fetchUsers());

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

App

このアプリでは、 App.tsx は Redux に関係ないのでスキップします。

Components

Components という名前が components ディレクトリかと紛らわしいけど、 features ディレクトリを見ます。

Redux Style Guide の Structure Files as Feature Folders with Single-File Logic にも feature folder で Reducer ロジックは1ファイルにまとめることを強く推奨としています。

PostsList.tsx を見ると postsSlice から state や dispatch を import しています。

このように、各 React コンポーネントは、 必要となる state と連携し、 Reducer はコンポーネントからの dispatch を通じて state を再生成します。

import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { fetchPosts, selectPostIds, selectPostById } from './postsSlice';
import { Link } from 'react-router-dom';
import { useEffect } from 'react';
import { EntityId } from '@reduxjs/toolkit';

// 省略

export const PostsList = () => {
  const dispatch = useAppDispatch();
  const orderedPostIds = useAppSelector(selectPostIds);

  const postStatus = useAppSelector((state) => state.posts.status);
  const error = useAppSelector((state) => state.posts.error);

  useEffect(() => {
    if (postStatus === 'idle') {
      dispatch(fetchPosts());
    }
  }, [postStatus, dispatch]);

  let content;

Reducer

Redux の reducer のロジックに関係するファイルは、xxxSlice.ts (小文字で始まる) です。

RTK の根幹になります。

ここでは、各 State に対して createEntityAdapter を生成して以下のように管理します。

なぜそうするべきなのか気になったら、 Redux Essentials, Part 6: Performance and Normalizing Data を再度読みましょう。

{
  // The unique IDs of each item. Must be strings or numbers
  ids: []
  // A lookup table mapping entity IDs to the corresponding entity objects
  entities: {
  }
}

基本的な CRUD 操作に関する関数を揃えているだけでなく、 汎用的な State を getSelectors 関数を使って取得できます。

const postsAdapter = createEntityAdapter<Post>({
  sortComparer: (a, b) => b.date.localeCompare(a.date),
});

// 省略

// Export the customized selectors for this adapter using `getSelectors`
export const {
  selectAll: selectAllPosts,
  selectById: selectPostById,
  selectIds: selectPostIds,
  // Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors((state: RootState) => state.posts);

さらに、引数に上記の selector 関数 を使った createSelector で、いい感じのデータをまとめられます。

export const selectPostsByUser = createSelector(
  [selectAllPosts, (state: RootState, userId: EntityId) => userId],
  (posts, userId) => posts.filter((post) => post.user === userId)
);

これらを React コンポーネントで useSelector などを使って読み込んで UI を更新し、その更新された値に対して、またイベントを発火して再び State を更新していきます。

UI 上でイベント発火された際に State を Reduce するための dispatch , action を createSlice で生成します。

const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    reactionAdded(
      state,
      action: PayloadAction<{ postId: string; reaction: keyof Reaction }>
    ) {
      const { postId, reaction } = action.payload;
      const existingPost = state.entities[postId];
      if (existingPost) {
        existingPost.reactions[reaction]++;
      }
    },
    postUpdated(
      state,
      action: PayloadAction<Pick<Post, 'id' | 'title' | 'content'>>
    ) {
      const { id, title, content } = action.payload;
      const existingPost = state.entities[id];
      if (existingPost) {
        existingPost.title = title;
        existingPost.content = content;
      }
    },
  },

非同期的な関数を reducer で扱いたい場合は、 createAsyncThunk を定義して、createSlice に extraReducers オプションで対応します。

export const fetchPosts = createAsyncThunk<Post[]>(
  'posts/fetchPosts',
  async () => {
    const response = await client.get('/fakeApi/posts');
    return response.data;
  }
);

// 省略

  extraReducers(builder) {
    builder
      .addCase(fetchPosts.pending, (state, action) => {
        state.status = 'loading';
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        // Add any fetched posts to the array
        postsAdapter.upsertMany(state, action.payload);
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = 'failed';
        if (action.error.message) {
          state.error = action.error.message;
        }
      });

これは見れば比較的に特徴的なので、すぐ思い出せる箇所と思います。

以上で冒頭の図の説明は終わりです、思い出したでしょうか。

使い方の詳細は Redux Toolkit の API Reference を見ましょう。

これでしばらく時を経ても、このブログを見れば、スムーズにやりたいことに着手できるのではないでしょうか。

そして、次回これを見るときにめちゃめちゃ RTK の仕様が変わっていないことを祈っています。

以上です。

未来の自分のためのまとめではありますが、どなたかのお役に立てば幸いです。

参考