10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

typelessに入門する

Posted at

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が生成される

実際に生成されるActionの例
console.log(TodosActions.addTodo('hoge');
// => {payload: {text: 'hoge'}, type: [Symbol('todos'), 'ADD_TODO']}

Reducers

Stateの定義

createModuleTodosModule における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処理は↓を使うものとする

API.ts
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;
  })

上記の例では visibilityFilterSHOW_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から

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

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;
        }
      }),
    };
  });

addTodotoggleTodo はAPIで一覧を取得するのだから必要ないと思われるかもしれないが、 説明の都合上仕方なくAPI呼び出しを待たずに画面に反映することでUXを向上させるという試みで入れてある

先述した .replacetoggleTodo で利用しているが、配列操作を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インスタンスを取得する必要がある

自前でActionをdispatchしてみる例
// 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を待ち受ける
    1. なんらかのActionがdispatchされる
    2. 発火されたActionに対応するReducer/Epic に登録されたハンドラを実行する
      • ReducerであればStoreの持つ状態を更新する
    3. 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して普通に使うことも可能
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として分離されていたとしても渡すべき状態が減るわけじゃないので一緒
  • Storybookで運用する際に問題になるのでは?
    • テストのところでも述べたような理由で問題にならない
      • Storybook内で利用するためのヘルパ関数(TypelessProviderでラップする関数)を用意してやればよい
    • むしろreducerを操作すれば任意の状態を再現できるので便利
      • ModuleHandler#reducer で初期状態をセットできるが、これを各storyで叩いてやれば自由に状態を定義できる

以上の理由でContainerComponentどんどん使っていく、で良いと思っている


おわりに

というわけでtypeless入門解説でした
ユニットテストやStorybookでの運用方法、実装パターン集とかはまたそのうちやりたい

今回のサンプル実装を置いたリポジトリはこちら↓
https://github.com/sisisin-sandbox/sample-typeles-todo/tree/master/src

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?