🥵

Reduxによる状態管理

2024/11/20に公開

今回はMattermostというオープンソースのSlackクローンアプリの状態管理で使われているReduxというライブラリに触れていきたいと思います。

https://redux.js.org/

Mattermostのビルド方法については以下の記事で解説しています。
https://zenn.dev/masan_eeic/articles/5f7441be8a3567

Reduxとは?

ReduxはJavascriptやReactアプリケーションでの状態管理を効率的に行うためのライブラリです。
しかし、Reactの状態管理で、よく使われるのはuseStateuseContextフックだと思います。実際、簡単なシングルページwebアプリケーションなどであれば、useStateで十分に状態管理を行うことができると思いますし、ページ間でのちょっとした状態管理であればuseContextを使えば事足りることが多いと思います。ReduxuseStateuseContextでは管理しきれないような大規模なアプリケーションの状態管理に用いられることが多いようです。

https://ja.react.dev/reference/react/useState

https://ja.react.dev/reference/react/useContext

Reduxの仕組み

Reduxの状態管理の概念図は、以下のようになっている。登場する概念は大きく以下の3つです。

  1. Store(アプリケーション全体の状態を一元的に管理する)
  2. Action(状態を変更するために発行されるイベントのようなもの)
  3. ReducerActionの基づいてどのように状態を変更するか定義する)

Reduxの具体的な実装

今回は授業課題の一環として、投稿のドロップダウンメニューにその投稿を翻訳するという機能を実装することを目標としました。この実装の中で、Reduxによる状態管理を利用したので、それを元にReduxを利用した具体的な実装を解説していきます。(軽微な編集も含めれば、実際には編集するファイルの数は膨大なので、ここでは重要な部分のみを提示します)

1. ドットメニューに翻訳ボタンを追加する

  • DotMenuコンポーネントに翻訳用のMenuItemボタンを追加
  • translatePostという関数を受け取るようにPropsに追加
mattermost/webapp/channels/src/components/dot_menu/dot_menu.tsx
@@ -122,6 +123,11 @@ type Props = {
         */
        setThreadFollow: (userId: string, teamId: string, threadId: string, newState: boolean) => void;

+        /**
+         * Function to translate the Post Content
+         */
+        translatePost: (post: Post, locale: string) => void;
    }; // TechDebt: Made non-mandatory while converting to typescript

    canEdit: boolean;
@@ -327,6 +333,10 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
        );
    };

+    handleTranslateMenuItemActivated = (e: ChangeEvent) => {
+        this.props.actions.translatePost(this.props.post, this.props.intl.locale);
+    }
+
    handleCommentClick = (e: ChangeEvent) => {
        trackDotMenuEvent(e, TELEMETRY_LABELS.REPLY);
        this.props.handleCommentClick?.(e);
@@ -695,6 +705,20 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
                        isDestructive={true}
                    />
                }
+                <Menu.Item
+                    id={`translate_post_${this.props.post.id}`}
+                    data-testid={`translate_post_${this.props.post.id}`}
+                    labels={
+                        <FormattedMessage
+                            id='post_info.translate'
+                            defaultMessage='Translate'
+                        />
+                    }
+                    leadingElement={<GlobeCheckedIcon size={18}/>}
+                    trailingElements={<ShortcutKey shortcutKey='T'/>}
+                    onClick={this.handleTranslateMenuItemActivated}
+                />
            </Menu.Container>
        );
    }

ソースコード

https://github.com/mattermost/mattermost/blob/master/webapp/channels/src/components/dot_menu/dot_menu.tsx

2. Actionの定義

  • 先ほど定義したtransaltePost関数をdispatchする関数を定義する
mattermost/webapp/channels/src/actions/post_actions.ts
@@ -432,3 +440,10 @@ export function emitShortcutReactToLastPostFrom(emittedFrom: keyof typeof Consta
        payload: emittedFrom,
    };
}

+export function translatePostContent(post: Post): ActionFuncAsync {
+    return async (dispatch) => {
+        dispatch(PostActions.translatePost(post));
+        return {data: true};
+    };
+}

ソースコード
https://github.com/mattermost/mattermost/blob/master/webapp/channels/src/actions/post_actions.ts

3. DispatchされたActionに対しての翻訳処理の実装

  • 翻訳を行うバックエンドAPIであるClient4に対してPostのデータを送って翻訳を行う
  • 翻訳されたtranslatedPostをdispatchする
mattermost/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts

@@ -1130,6 +1130,23 @@ export function removePost(post: ExtendedPost): ActionFunc<boolean> {
    };
}

+export function translatePost(post: Post): ActionFuncAsync {
+    return async (dispatch, getState) => {
+        try {
+            const translatedPost = await Client4.translatePost(post);
+            dispatch({
+                type: TRANSLATE_POST_SUCCESS,
+                data: translatedPost,
+            });
+        } catch (error) {
+            forceLogoutIfNecessary(error, dispatch, getState);
+            dispatch(logError(error));
+            return {error};
+        }
+        return {data: true};
+    };
+}
+
export function moveThread(postId: string, channelId: string): ActionFuncAsync {
    return async (dispatch, getState) => {
        try {

ソースコード
https://github.com/mattermost/mattermost/blob/master/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts

4. バックエンドと通信して翻訳処理を行う部分を実装

  • バックエンドサーバーはGo言語で記述されているので、具体的な実装は別メンバーの記事で紹介されています。
  • ここでは、簡易的に翻訳メッセージを返す処理を書いています。
mattermost/webapp/platform/client/src/client4.ts

@@ -1,6 +1,20 @@
+    translatePost = (post: Post) => {
+        // バックエンドがまだ実装されていないので、モックデータを返します
+        return new Promise<Post>((resolve) => {
+            // モックデータ: ここでは`message`が翻訳されたと仮定して、"Translated: "を追加
+            const translatedMessage = `Translated: ${post.message}`;
+            const newPost = {
+                ...post,
+                message: translatedMessage,  // 翻訳後のメッセージをセット
+            };
+            
+            // 1秒後にモックデータを返す
+            setTimeout(() => {
+                resolve(newPost);
+            }, 1000);
+        });
+    };
    
    getPostThread = (postId: string, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) => {
        // this is to ensure we have backwards compatibility for `getPostThread`

ソースコード
https://github.com/mattermost/mattermost/blob/master/webapp/platform/client/src/client4.ts

5. Reducderで状態をStoreに反映させる

  • ActionTypesで定義したTRANSLATE_POST_SUCCESS(実装自体は省略)に対して、具体的にStoreをどう更新するかの処理を書く
mattermost/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/posts.ts

@@ -323,6 +324,23 @@ export function handlePosts(state: IDMappedObjects<Post> = {}, action: AnyAction

    case UserTypes.LOGOUT_SUCCESS:
        return {};
+    case TRANSLATE_POST_SUCCESS: {
+        const translatedPost = action.data;
+        // 既存のポストを更新する
+        const nextState = {
+            ...state,
+            [translatedPost.id]: {
+                ...state[translatedPost.id],
+                message: translatedPost.message, // メッセージを翻訳された内容に更新
+                translated: true, // 翻訳済みのフラグを追加
+            },
+        };
+        return nextState;
+    }
    default:
        return state;
    }
@@ -1620,3 +1638,4 @@ export default function reducer(state: Partial<PostsState> = {}, action: AnyActi

    return nextState;
}

ソースコード
https://github.com/mattermost/mattermost/blob/master/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts

Reduxのメリットとデメリット

Reduxによる状態管理のメリットとしては、以下の点があると思います。

  • useStateに比べて状態変数に対しての操作のTYPEなどが定義できて、状態変数への操作の種類が増してきたときに、管理がしやすくなる
  • useContextに比べて、状態変数の種類が膨大でも管理が行いやすく、また、状態変数の変更履歴なども保持することができる
  • とにかく、大規模なアプリケーションにおいて、様々な状態変数の管理と状態変数の操作がある場合にはスケーラビリティの観点から見てもReduxを利用することはメリットがある

一方で、以下のようなデメリットもあるように思いました。

  • そもそも処理が複数のファイルに分割されすぎていて、よくわからない(今回の機能追加においても、一つの機能を追加するだけで操作するべきファイルの数が膨大すぎて正直よくわからなくなる)
  • ちょっとした機能を追加するだけでも、ActionDispatchReducerを別々で定義する必要があり、かなり面倒
  • よっぽど大規模なアプリでない限り、useContextuseStateで事足りているのではないか?と思う。

Mattermostを通してReduxに触れてみての感想

  • Mattermostは非常に大規模なアプリケーションで、ファイルの数が膨大であるし、同じ名前のファイルがたくさん存在しているため、どこに何が実装されているのかがよくわからなくなります。
  • もっと、それぞれのディレクトリやファイルの責務を明確にしたドキュメントなどがあると私のような初学者でも触りやすくなるのではないかと感じました。
  • ReactuseReducerReduxに似ているので、まずはそこからトライするのがいいように思いました笑。(後日談)

https://ja.react.dev/reference/react/useReducer

Discussion