typelessとは
Reactで利用するためのfluxライブラリ
TypeScriptフレンドリーで、「type annotation less」にアプリケーションが書けることを標榜している
React hooksによって様々な機能を実現・提供しているので、Reactでのみ利用できる
特徴として、
- TypeScriptフレンドリーに作られており、型アノテーションなしに型安全を実現できる
- ActionCreator,Reducer,Epic(SideEffect操作)を1ライブラリですべて取り扱える
- ActionCreator+Reducer+Epicを1featureと言う単位で取り扱い、このfeatureはReact hooksを使ってReactComponentとして表現される
- モジュールはモジュールComponentがマウントされている間だけ有効化されており、明示的にマウントしていないモジュールはReducerやEpicが動かない
まあ大体Reduxの抱える課題をまるっと解決してTypeScriptで安全にアプリケーションを書けるというライブラリとなっている
このtypelessというライブラリについて、ReduxのBasicチュートリアルを参考に、Todoアプリをお題に基本的な使い方を解説をしようと思う
Moduleの定義
typelessではReducer/Epicを作るためにまず createModule
関数で ModuleHandler
を定義する必要がある
ここで定義したModuleHandler
にReducer/Epic処理を登録していくことになる
ちなみにこの ModuleHandler
は内部的に Store
というクラスのインスタンスを持っていて、そこにReducer,Epic処理を持つという仕組みになっている
ModuleHandler
は React hooksになっていて、React Component内でのみ呼び出すことが可能
そして、 Componentとしてマウントされている間のみReducer,Epicは有効となる
const TodosSymbol = Symbol('todos');
const [useTodosModule] = createModule(TodosSymbol);
// Reducer,Epic登録
// ...
// このTodosModuleコンポーネントがマウントされている間のみTodosModule内のReducer/Epicが有効
const TodosModule = () => {
useTodosModule()
return <div>some todo views</div>;
};
Actions,ActionCreators
Action及びActionCreatorを定義する
typelessでは createModule
及び withActions
関数を使ってこれらを定義する
withActions
を呼び出すと返り値の配列にActionCreatorが含まれるようになる
Reduxチュートリアルで定義されていた addTodo
, toggleTodo
, setVisibilityFilter
に加えてAPIからTodo一覧を取得するためのActionも定義してみる
const TodosSymbol = Symbol('todos');
-const [useTodosModule] = createModule(TodosSymbol);
+const [useTodosModule, TodosActions] = createModule(TodosSymbol)
+ .withActions({
+ $mounted: null,
+ fetchTodo: null,
+ fetchTodoFulfilled: (todos: Todo[]) => ({ payload: { todos } }),
+ addTodo: (text: string) => ({ payload: { text } }),
+ toggleTodo: (idx: number) => ({ payload: { idx } }),
+ setVisibilityFilter: (filter: VisibilityFilter) => ({ payload: { filter } }),
+ });
+
+type VisibilityFilter = 'SHOW_ALL' | 'SHOW_COMPLETED' | 'SHOW_ACTIVE';
withActions
は、引数のオブジェクトのkeyにActionの名称、valueにActionCreator関数の実装を渡す
上の例だと、 addTodo
というActionは string
を引数にとって、 { payload: { text: string } }
というオブジェクトを返すActionCreatorによって生成される、という形になる
実際に利用する際は TodoActions.addTodo
関数を呼び出すことによってActionオブジェクトを得られる
このとき、ActionTypeはtypeless側で補完してくれて、 createModule
に渡したSymbolを元に一意なActionTypeが生成される
console.log(TodosActions.addTodo('hoge');
// => {payload: {text: 'hoge'}, type: [Symbol('todos'), 'ADD_TODO']}
Reducers
Stateの定義
createModule
で TodosModule
におけるReducerで利用するStateの型を定義し、 StateGetter
を取得する。
withState<T>
メソッドを呼び出すと返り値の配列に StateGetter
が含まれるようになり、Reducerが利用できるようになる
-const [useTodosModule, TodosActions] = createModule(TodosSymbol)
+const [useTodosModule, TodosActions, getTodosState] = createModule(TodosSymbol)
.withActions({
$mounted: null,
fetchTodo: null,
fetchTodoFulfilled: (todos: Todo[]) => ({ payload: { todos } }),
addTodo: (text: string) => ({ payload: { text } }),
toggleTodo: (idx: number) => ({ payload: { idx } }),
setVisibilityFilter: (filter: VisibilityFilter) => ({ payload: { filter } }),
- });
+ })
+ .withState<TodosState>();
type VisibilityFilter = 'SHOW_ALL' | 'SHOW_COMPLETED' | 'SHOW_ACTIVE';
+interface Todo {
+ text: string;
+ completed: boolean;
+}
+interface TodosState {
+ todos: Todo[];
+ visibilityFilter: VisibilityFilter;
+}
この Todos
に関する状態を取得するときは、ここで得られた getTodosState
を利用することになる
Actionに対してのReducer/Epic処理を定義する
NOTE: API処理は↓を使うものとする
import { Todo } from 'features/todos/interface';
let todos: Todo[] = [{ text: 'initial todo', completed: true }, { text: 'initial todo2', completed: false }];
const sleep = () =>
new Promise(done => {
setTimeout(() => done(), 300);
});
export const getAllTodos = () => sleep().then(() => todos);
export const addTodo = async (text: string) => {
const todo: Todo = { text, completed: false };
todos = [...todos, todo];
await sleep();
};
export const toggleTodo = async (idx: number) => {
todos = todos.map((todo, i) => {
if (i === idx) {
return { ...todo, completed: !todo.completed };
} else {
return todo;
}
});
await sleep();
};
Reducer
typelessのReducerは一般的なReducerと同様、 initialState
と任意のActionに対しての状態更新処理関数の実装によって定義される
このReducer処理はReduxなどのように純粋関数でなければならない(副作用を発生させてはならない)
Reducer定義は .reducer(initialState)
関数で初期状態を定義、 .on
関数で、第一引数に指定したActionのときに、どのように状態を更新するかを定義する
const initialState: TodosState = {
visibilityFilter: 'SHOW_ALL',
todos: [],
};
useTodosModule
// ここで初期状態を定義する
.reducer(initialState)
// ここで任意のActionに対する状態更新処理を定義する
.on(TodosActions.setVisibilityFilter, (state, { filter }) => {
state.visibilityFilter = filter;
})
上記の例では visibilityFilter
が SHOW_ALL
, todos
が []
という初期状態を useTodosModule.reducer(initialState)
の呼び出しによって定義、続く on
関数で任意のActionがdispatchされたときの状態更新処理を記述している
注意点として、 immer を利用しているので state
の更新処理は mutableな変更 でなければならない
immutableな変更をしたい場合は replace
関数を利用することになる
Epic
typeless における副作用はEpicで行う
EpicもReducer同様に .on
関数を利用して、指定したActionに対して実施する副作用関数を定義していくことになる
この副作用のあとに新しくActionを発火したい場合は、ハンドラ関数の返り値にActionをreturnしてやれば良い
また、このハンドラ関数はPromise,Observableを返り値として取れるので、 Promise<Action>
や Observable<Action>
を返すことも可能
この副作用関数の処理の後に特にActionを発火したくないという場合は、 null
| Promise<null>
| Observable<null>
を返せばよい
useTodosModule
.epic()
.on(TodosActions.fetchTodo, async () => {
const todos = await API.getAllTodos();
return TodosActions.fetchTodoFulfilled(todos);
})
Todo一覧をAPIから取得し、追加・変更が出来るようにReducer,Epic を追加する
まずEpicから
useTodosModule
.epic()
.onMany([TodosActions.$mounted, TodosActions.fetchTodo], async () => {
// todoを全件取得してfetchTodoFulfilled Actionを発火させる
const todos = await API.getAllTodos();
return TodosActions.fetchTodoFulfilled(todos);
})
.on(TodosActions.addTodo, async ({ text }) => {
// todoを追加して、追加が済んだらfetchTodo Actionを発火させて一覧を最新のものにする
await API.addTodo(text);
return TodosActions.fetchTodo();
})
.on(TodosActions.toggleTodo, async ({ idx }) => {
// addTodoと同じく変更・一覧更新をする
await API.toggleTodo(idx);
return TodosActions.fetchTodo();
});
.onMany
という関数で複数のActionを待ち受ける事ができるので、一覧取得処理をここで一括で定義している
また、 画面初期ロード時に一覧が取得したいので $mounted
という組み込みActionを利用している。これはモジュールのマウント時に自動的に発火させてくれる
よくあるパターンだが、initializeのようなActionを手で発火させる必要がなくて便利
注意点として、 withActions
関数で 明示的に定義されていないと発火されない という点が挙げられる。
どうも全ての組み込みActionが勝手に発火すると開発者ツールで見れるログが非常にやかましくなるので抑制する作りになっているっぽい
次にReducer
const initialState: TodosState = {
visibilityFilter: 'SHOW_ALL',
todos: [],
};
useTodosModule
.reducer(initialState)
.on(TodosActions.setVisibilityFilter, (state, { filter }) => {
state.visibilityFilter = filter;
})
.on(TodosActions.fetchTodoFulfilled, (state, { todos }) => {
state.todos = todos;
})
.on(TodosActions.addTodo, (state, { text }) => {
state.todos.push({ text, completed: false });
})
.replace(TodosActions.toggleTodo, (state, { idx }) => {
return {
...state,
todos: state.todos.map((todo, i) => {
if (i === idx) {
return { ...todo, completed: !todo.completed };
} else {
return todo;
}
}),
};
});
addTodo
と toggleTodo
はAPIで一覧を取得するのだから必要ないと思われるかもしれないが、 説明の都合上仕方なくAPI呼び出しを待たずに画面に反映することでUXを向上させるという試みで入れてある
先述した .replace
をtoggleTodo
で利用しているが、配列操作をmutableに書くと読みにくい場合なんかに使い分けたり、 initialState
をセットし直したい、といった場合に便利なAPIとなっている
Moduleを分割・・・しない
ReduxチュートリアルではReducerを分割してるけど正直あまりいい例じゃないと思うので一旦なしで
typelessにおける module の分割をしたければ、別途 createModule
を利用して新しい ModuleHandler
を作ってあげれば良い
多くのSPAでは1つのURLに対して1モジュールになると思う
具体例は公式のexapmlesなどを参照ということで
https://typeless.js.org/introduction/examples
RegistryとStore
Store
typelessにおけるStoreはRedux同様dispatch関数とstateを保持して発火されたActionに応じて必要なら自身のstateを更新する役割を持つ
Reduxとは異なる点としては
-
createModule
によって生成される1単位である- つまりアプリ内で複数生成されることになる
- 有効・無効の概念があり、ReducerやEpicの処理は有効化されている間にのみ働く
- Epicを内包しており、Storeに紐付いた副作用に関する処理も紐付いている
Registry
RegistryはtypelessにおけるRedux Storeみたいなもので、全ての状態を中央集権的に管理するクラス
各 ModuleHandler
(ここまでの例だと useTodosModule
) で定義されたReducerはこの Registry
に登録されることになる
後述する DefaultTypelessProvider
を利用する場合は特に利用者側で触る必要はない
Actionをdispatchする
typelessのRegistryもdispatch関数を持っていて、ユーザーが自由にこの関数を叩くことで明示的にdispatchを呼ぶことが出来る
ただし、Registryにはsubscribe関数は用意されていないので、もしsubscribeをしたい場合はStoreインスタンスを取得する必要がある
// TodosModuleによって定義されたStoreインスタンスを取得し、状態更新をsubscribeする
defaultRegistry.getStore(TodosSymbol).subscribe(() => {
console.log(getTodosState());
});
export const TodosModule = () => {
// モジュールの有効化
useTodosModule();
// Actionをdispatchする
defaultRegistry.dispatch(TodosActions.addTodo('hoge'));
defaultRegistry.dispatch(TodosActions.toggleTodo(0));
return null;
};
Data Flow
typelessもRedux同様厳密な単一方向データフローになっている(Fluxなんだからそれはそう
Storeは各々独立に以下のデータ更新サイクルに基づいた振る舞いをする
- Module Componentがマウントされる
- 初期Stateがセットされる
- Actionのdispatchを待ち受ける
- なんらかのActionがdispatchされる
- 発火されたActionに対応するReducer/Epic に登録されたハンドラを実行する
- ReducerであればStoreの持つ状態を更新する
- 1,2を繰り返す
- Module Componentがアンマウントされる
- dispatchの待ち受けを解除する
Componentでこれらの状態を参照する
typelessではComponentから各種Storeの状態を参照したりActionをdispatchする場合、React hooksとして提供されているAPIを利用することになる
-
useMappedState(StateGetter[], (...state) => state)
- 引数に渡した
StateGetter
から状態を取得する - mapStateToPropsみたいなもん
- 引数に渡した
-
useActions(ActionCreator)
- 引数に渡した
ActionCreators
をdispatchする関数を取得する - mapDispatchToPropsみたいなもん
- 引数に渡した
このAPIを利用したComponentはReduxでいう ContainerComponent
に相当する働きをする
TodoアプリのComponent設計
- VisibleTodoList
- visibilityFilterの状態に応じて、現在表示すべきtodo一覧を出す
- AddTodo
- input要素に与えられたテキストを元に新たなTodoを追加する
- VisibilityFilters
- VisibilityFilterを操作する
- TodosView
- 上記3つのコンポーネントを配置した、Todoに関するRootコンポーネント
- TodosModule
- TodosViewのために必要なTodosModuleを有効化するコンポーネント
- App
- このTodoアプリのルートコンポーネント
- TypelessProviderを記述している
VisibleTodoList
-
useMappedState
でvisibilityFilterに応じたtodoを取得してComponentで描画 - 各Todoの要素には
onClick
で状態をtoggleする処理をもたせる
const VisibleTodoList = () => {
const todos = useMappedState([getTodosState], ({ todos, visibilityFilter }) => {
switch (visibilityFilter) {
case 'SHOW_ALL':
return todos;
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed);
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed);
}
});
const { toggleTodo } = useActions(TodosActions);
return (
<ul>
{todos.map(({ text, completed }, i) => (
<li
key={i}
onClick={() => toggleTodo(i)}
style={{
textDecoration: completed ? 'line-through' : 'none',
}}
>
{text}
</li>
))}
</ul>
);
};
AddTodo
- formのsubmit時にinput要素の値を
addTodo
Actionの引数として渡してやるだけ
const AddTodo = () => {
const input = React.useRef<HTMLInputElement>();
const { addTodo } = useActions(TodosActions);
return (
<div>
<form
onSubmit={e => {
e.preventDefault();
if (!input.current!.value.trim()) {
return;
}
addTodo(input.current!.value);
input.current!.value = '';
}}
>
<input
ref={node => {
input.current = node!;
}}
/>
<button type="submit">Add Todo</button>
</form>
</div>
);
};
VisibilityFilters
-
FilterLink
コンポーネントは、クリックされたときに指定されたVisibilityFilter
についてsetVisibilityFilter
Actionを発火する - 現在利用されているFilterと指定された
VisibilityFilter
が同じだったらボタンではなく単なるテキストを表示する
VisibilityFilters
コンポーネントは、FilterLink
をフィルター種別分だけ列挙しているだけ
const FilterLink: React.FC<{ filter: VisibilityFilter }> = ({ children, filter }) => {
const { setVisibilityFilter } = useActions(TodosActions);
const { visibilityFilter } = useMappedState([getTodosState], state => state);
if (visibilityFilter === filter) {
return <span>{children}</span>;
}
return (
<button
onClick={e => {
e.preventDefault();
setVisibilityFilter(filter);
}}
>
{children}
</button>
);
};
const VisibilityFilters = () => {
return (
<p>
Show:<FilterLink filter="SHOW_ALL">ALL</FilterLink>
{', '}
<FilterLink filter="SHOW_ACTIVE">Active</FilterLink>
{', '}
<FilterLink filter="SHOW_COMPLETED">Completed</FilterLink>
</p>
);
};
App
- typelessのProviderを記述している
- DefaultTypelessProviderを利用する場合はregistryは組み込みの
defaultRegistry
が利用されている - defaultRegistryはimportして普通に使うことも可能
- DefaultTypelessProviderを利用する場合はregistryは組み込みの
export const App: React.FC = () => {
return (
<DefaultTypelessProvider>
<TodosModule />
</DefaultTypelessProvider>
);
};
ちなみに自前でregistryをインスタンス化する場合はこう
const registry = new Registry();
export const App: React.FC = () => {
return (
<TypelessContext.Provider value={{ registry }}>
<TodosModule />
</TypelessContext.Provider>
);
};
*余談
dispatchにmiddleware相当の処理を挟みたい場合は、このRegistryクラスを継承してdispatch関数をoverrideした自前Registryクラスのインスタンスを渡してあげれば良い
Appをレンダリング
いつものindex.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './App';
ReactDOM.render(<App />, document.getElementById('root'));
TodoアプリのComponent設計についての考察
今回作った VisibleTodoList
, AddTodo
, VisibilityFilters
コンポーネントらはどれも typeless moduleに依存するContainerComponent相当の作りになっている
これらをmoduleに依存しない Presentational Components として実装しなくてよいのか?という点については、自分は必要ならそのとき分割すればよい、と考えている
基本的にはよっぽど汎用的なコンポーネント(例えばボタンとか)の粒度じゃなければContainerComponentとして書いてしまっても問題がなかろうという考えで、理由としては
- 分割したいときに、苦労しないで済む
- hooksの制約上、if文などの中で呼び出しができない = Componentのトップレベルで、しかも基本的にコンポーネント処理の最初に記載しなければならない
- この制約によって、いざ分割がしたいと思った場合、該当部分を切り取って親子関係に分離するだけで完了する
- typelessを利用する場合、TypeScriptを利用するのがほぼ前提なので、リファクタ時に静的型の恩恵を受けるのは前提となる
- 静的型の支援があれば一般的にはリファクタ時に事故りにくい
- テスタビリティにも影響が少ない
- ContainerComponentをテストしようと思ったらそもそも大変
- typelessでテストをする場合、テストコード内でProviderを定義してやれば十分テスト可能
- ボタン押下でAction発火のテスト→
Registry#dispatch
をspyしてやればよい - 指定した状態におけるレンダリングのテスト→PresentationalComponentとして分離されていたとしても渡すべき状態が減るわけじゃないので一緒
- ボタン押下でAction発火のテスト→
- Storybookで運用する際に問題になるのでは?
- テストのところでも述べたような理由で問題にならない
- Storybook内で利用するためのヘルパ関数(TypelessProviderでラップする関数)を用意してやればよい
- むしろreducerを操作すれば任意の状態を再現できるので便利
-
ModuleHandler#reducer
で初期状態をセットできるが、これを各storyで叩いてやれば自由に状態を定義できる
-
- テストのところでも述べたような理由で問題にならない
以上の理由でContainerComponentどんどん使っていく、で良いと思っている
おわりに
というわけでtypeless入門解説でした
ユニットテストやStorybookでの運用方法、実装パターン集とかはまたそのうちやりたい
今回のサンプル実装を置いたリポジトリはこちら↓
https://github.com/sisisin-sandbox/sample-typeles-todo/tree/master/src