はじめに
本投稿はReact hooks-oriented Redux coding pattern without thunks and action creatorsからの抜粋です。比較的小さいアプリを作る際に自分ならこうするというのを書いたものです。ポイントは2つです。
- 非同期ミドルウエアを使わない (Reduxの外で非同期処理をする)
- Action creator関数を使わない (TypeScriptの型定義で済ませる)
Reduxの公式チュートリアルのAsync Actionsの例題を使います。
Actionの定義
ActionはTypeScriptの型として定義します。Actionオブジェクトを生成する関数は作りません。TypeScriptの型チェックで十分だと思うからです。
export type Post = {
id: string;
title: string;
};
export type SubredditPosts = {
isFetching: boolean;
didInvalidate: boolean;
items: Post[];
lastUpdated?: number;
};
export type PostsBySubreddit = {
[subreddit: string]: SubredditPosts;
};
export type SelectedSubreddit = string;
export type State = {
selectedSubreddit: SelectedSubreddit;
postsBySubreddit: PostsBySubreddit;
};
type SelectSubredditAction = {
type: 'SELECT_SUBREDDIT';
subreddit: string;
};
type InvalidateSubredditAction = {
type: 'INVALIDATE_SUBREDDIT';
subreddit: string;
};
type RequestPostsAction = {
type: 'REQUEST_POSTS';
subreddit: string;
};
type ReceivePostsAction = {
type: 'RECEIVE_POSTS';
subreddit: string;
posts: Post[];
receivedAt: number;
};
export type Action =
| SelectSubredditAction
| InvalidateSubredditAction
| RequestPostsAction
| ReceivePostsAction;
カスタムフックで非同期処理
Reduxは同期的なステート管理の役割に絞って、非同期処理はReact側でやる方針です。redux-thunkを使う場合と比べて、TypeScriptの型推論が自然にできます。
import { useCallback } from 'react';
import { useDispatch, useStore } from 'react-redux';
import { Action, State, Post } from '../store/actions';
const shouldFetchPosts = (state: State, subreddit: string) => {
const posts = state.postsBySubreddit[subreddit];
if (!posts) {
return true;
}
if (posts.isFetching) {
return false;
}
return posts.didInvalidate;
};
const extractPosts = (json: unknown): Post[] | null => {
try {
const posts: Post[] = (json as {
data: {
children: {
data: {
id: string;
title: string;
};
}[];
};
}).data.children.map(child => child.data);
// type check
if (posts.every(post => (
typeof post.id === 'string' && typeof post.title === 'string'
))) {
return posts;
}
return null;
} catch (e) {
return null;
}
};
const useFetchPostsIfNeeded = () => {
const dispatch = useDispatch<Action>();
const store = useStore<State>();
const fetchPostsIfNeeded = useCallback(async (subreddit: string) => {
if (!shouldFetchPosts(store.getState(), subreddit)) {
return;
}
dispatch({
type: 'REQUEST_POSTS',
subreddit,
});
const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`);
const json = await response.json();
const posts = extractPosts(json);
if (!posts) throw new Error('unexpected json format');
dispatch({
type: 'RECEIVE_POSTS',
subreddit,
posts,
receivedAt: Date.now(),
});
}, [dispatch, store]);
return fetchPostsIfNeeded;
};
export default useFetchPostsIfNeeded;
この関数をasync action hookと呼ぶことにします。
フォルダ構造
コンポーネントは素直にcomponentsに集約して、Redux系のロジックはstoreへ、Reactのロジックはhooksへまとめます。
- src/
- index.tsx
- store/
- actions.ts
- reducers.ts
- hooks/
- useSelectSubreddit.ts
- useInvalidateSubreddit.ts
- useFetchPostsIfNeeded.ts
- components/
- App.tsx
- Picker.tsx
- Posts.tsx
型付けされたRedux Hooks
毎回useDispatchやuseSelectorの型を指定するのが面倒な場合は、store.tsで型をつけてexportします。
import {
useSelector as useReduxSelector,
useDispatch as useReduxDispatch,
TypedUseSelectorHook
} from "react-redux";
type State = ...;
type Action = ...;
export const useSelector: TypedUseSelectorHook<State> = useReduxSelector;
export const useDispatch: () => Dispatch<Action> = useReduxDispatch;
デモ
このCodeSandboxで全てのコードと実際の動作を確認できます。
ただし、このデモはreactive-react-reduxで書かれているため、useStoreだけは特殊になっていますのでご注意ください。
おわりに
本投稿で紹介したパターンはReduxの使い方の一つではありますが、標準ではありません。Reduxのライブラリはunopinionatedで様々な使い方ができますが、そこがReduxの分かりにくさの一つでもあり、今は標準と呼べるものが提供されています。それが、Redux ToolkitというライブラリやStyle Guideといったドキュメントです。一般的に「Reduxを使う」と言う場合はこの標準に乗るのが良いと思います。Reduxをベースにした新しいパターンを試す・使うと言うのであれば色々アレンジも可能で、今回紹介したパターンもその一つです。