Help us understand the problem. What is going on with this article?

ReactのHooksを使ってReduxのTodoリストをReduxなしに書き換えたサンプル(TypeScript使用)

注意

わたしは趣味でreactを勉強しているものです。間違いなどありましたらご指摘お願いしますm(_ _)m

参考

ReduxのTodoリスト

こちらのサンプルをReactのHooksに書き換えてみます。

私がReactのHooksに書き換えてみたコードはこちら

バージョン

  • react : 16.8.6
  • typescript : 3.5.2
  • webpack : 4.35.0

1. Hooksの基本を復習

最初にHooksの使い方をおさらいすると理解が深まりました。知っている方は飛ばして大丈夫です。
Hooksは他にもありますが今回使ったHooksは以下3つなので、この3つを復習します。

  • useState
  • useContext
  • useReducer

1-1. useState

js

jsでuseStateのサンプル
import React, { useState } from "react";
import { render } from "react-dom";

const App = () => {
  const [text, setText] = useState("");

  return (
    <>
      <p>text: {text.split("").reverse().join("")}</p>
      <input value={text} onChange={e => setText(e.target.value)} />
    </>
  );
};

render(<App />, document.getElementById("root"));
  1. useState("") を呼び出します、引数に設定した値が初期値になります
  2. [text, setText] = useState("");text, setTextを受け取ります。textは初期値を空文字に設定したので、この時点では空文字が設定されています。
  3. setText("hogefuga") と実行すると、textの値がhogefugaと変わります。

s3hyuG00xE.gif

ts

tsでuseStateのサンプル
import * as React from "react";
import { render } from "react-dom";

interface IItem {
  id: number;
  name: string;
}

const App: React.FC = (): JSX.Element => {
  const [text, setText] = React.useState<string>("");
  const [items, setItems] = React.useState<IItem[]>([]);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setItems([...items, { id: items.length + 1, name: text }]);
    setText("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button>Add</button>
      {items.map((item: IItem) => <p>{item.name}</p>)}
    </form>
  );
};

render(<App />, document.getElementById("root"));

5zPwrMT1Ny.gif

  const [text, setText] = React.useState<string>("");
  const [items, setItems] = React.useState<IItem[]>([]);

useState<型>(初期値) とすることで型を指定します。

setStateを使うときに指定の型ではなかった場合はエラーにすることができます。

Screen Shot 2019-06-23 at 19.27.06.png

1-2. useContext

useContextの前にcreateContextを復習

jsでcreateContextのサンプル

以下の例は、App -> Toolbar -> ThemedButton で入れ子になっているコンポーネントです。
propsでバケツリレーすることなく、App -> ThemedButtonと値を受け渡せます。

import React, { createContext } from "react";
import { render } from "react-dom";

const { Provider, Consumer } = createContext();

const ThemedButton = ({ children }) => (
  <Consumer>
    {({ color, onClick }) => (
      <button style={{ backgroundColor: color }} onClick={onClick}>
        {children}
      </button>
    )}
  </Consumer>
);

const Toolbar = () => (
  <ThemedButton>buttonA</ThemedButton>
);

const App = () => (
  <Provider value={{ color: "skyblue", onClick: () => alert("sample!") }}>
    <Toolbar />
  </Provider>
);

render(<App />, document.getElementById("root"));

js

jsでuseContextのサンプル

↑のcreateContextのサンプルをuseContextから値を取得するように変えてみた例です。

import React, { createContext, useContext } from "react";
import { render } from "react-dom";

const ThemeContext = createContext();

const ThemedButton = ({ children }) => {
  const { color, onClick } = useContext(ThemeContext);
  return (
    <button style={{ backgroundColor: color }} onClick={onClick}>
      {children}
    </button>
  );
};

const Toolbar = () => <ThemedButton>buttonA</ThemedButton>;

const App = () => (
  <ThemeContext.Provider
    value={{ color: "skyblue", onClick: () => alert("sample!") }}
  >
    <Toolbar />
  </ThemeContext.Provider>
);

render(<App />, document.getElementById("root"));

ts

tsでuseContextのサンプル
import * as React from "react";
import { render } from "react-dom";

interface IContext {
  theme: string;
  color: string;
  font: string;
}

const ThemeContext = React.createContext<Partial<IContext>>({});

const ThemedButton: React.FC = ({ children }): JSX.Element => {
  const { color } = React.useContext(ThemeContext);
  return <button style={{ backgroundColor: color }}>{children}</button>;
};

const Toolbar: React.FC = (): JSX.Element => (
  <ThemedButton>buttonA</ThemedButton>
);

const App: React.FC = (): JSX.Element => (
  <ThemeContext.Provider value={{ color: "skyblue" }}>
    <Toolbar />
  </ThemeContext.Provider>
);

render(<App />, document.getElementById("root"));

React.createContext<Partial<IContext>>({});と型を指定しました。

React.createContext<IContext>({}); としてもいいのですが、初期値に値が一つもないとエラーになるようでした。
Parcialを使うとうまくいくようです。

// Partial<IContext>とすると、このように宣言したのと同じように扱ってくれる
interface IContext {
  theme?: string;
  color?: string;
  font?: string;
}

1-3. useReducer

js

jsでuseReducerのサンプル

公式のサンプルです。

https://reactjs.org/docs/hooks-reference.html#usereducer

import React, { useReducer } from "react";
import { render } from "react-dom";

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
};

render(<Counter />, document.getElementById("root"));
  1. useReducer(reducer, initialState);reducerとstateの初期値を渡します。
    • dispatchはstateを更新するときに呼び出す関数です。dispatchの引数は、dispatch({ type: "decrement", payload: hoge })} とするのが一般的なようです。
  2. dispatch({ type: "decrement" }) とすると、reducerが実行され、stateを変更してくれます。

ts

tsでuseReducerのサンプル
import * as React from "react";
import { render } from "react-dom";

interface IState {
  count: number;
}

interface IAction {
  type: "increment" | "decrement";
}

const initialState: IState = { count: 0 };

function reducer(state: IState, action: IAction) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

const Counter: React.FC = (): JSX.Element => {
  const [state, dispatch] = React.useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
};

render(<Counter />, document.getElementById("root"));

stateactionのそれぞれ型指定してみました。

2. ReactとTypeScriptの環境をwebpackで作る

ここからReactのHooksでTodoリストを作成してみます

2-1. インストール

yarn init -y
yarn add react react-dom
yarn add -D webpack webpack-cli webpack-dev-server ts-loader typescript @types/react @types/react-dom

2-2. webpack.config.js

webpack.config.js
module.exports = {
  mode: "development",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader"
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js"]
  },
  devServer: {
    port: 9000,
    contentBase: "./",
    publicPath: "/dist/",
    open: "Google Chrome"
  }
};

2-3. tsconfig.json

今回はこのような設定にしてみました。

tsconfig.json
{
  "compilerOptions": {
    // コンパイル後の.jsのバージョンを指定
    "target": "es5",
    // モジュールをどの形式にするかを指定 "None", "CommonJS", "AMD", "System", "UMD", "ES6", "ES2015" "ESNext".
    //   "ESNext"は、サポートされている最新のESが提案する機能を対象。
    // 今回は「typescriptで指定のモジュールに変換 -> tsで生成されたモジュールの形式でwebpackでバンドル」
    //   とするので、webpackが対応しているモジュールの形式ならどれ指定してもコンパイルできるようでした。
    // 【参考】webpackが対応しているモジュール:https://webpack.js.org/concepts/modules/
    "module": "esnext",
    // 厳密な型チェックオプションをすべて有効にする
    // trueにすると ↓ を全部trueにしたのと同じ意味
    // --noImplicitAny, --noImplicitThis, --alwaysStrict, --strictBindCallApply, --strictNullChecks, --strictFunctionTypes and --strictPropertyInitialization
    "strict": true,
    // .jsファイルもコンパイルできるように
    "allowJs": true,
    // jsxを変換するか
    //  "preserve":jsxを変換しない「<div />」->「<div />」、jsxの変換はbabelで行いたい場合などで使う
    //  "react":jsxを変換する「<div />」->「React.createElement("div")」
    //  "react-native":jsxを変換しない「<div />」->「<div />」
    "jsx": "react"
  },
  // コンパイルに含めるフォルダ
  "include": ["src"]
}

2-4. フォルダ構成

.
├── index.html
├── package.json
├── src
│   ├── components
│   │   ├── AddTodo.tsx
│   │   ├── App.tsx
│   │   ├── FilterLink.tsx
│   │   ├── Footer.tsx
│   │   ├── Store.tsx
│   │   ├── Todo.tsx
│   │   └── TodoList.tsx
│   ├── index.tsx # webpackの起点のファイル
│   ├── interface.ts
│   └── reducer.ts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

2-5. Todoリスト作成

index.html
index.html
<!DOCTYPE html>
<meta charset="UTF-8">
<title>Document</title>
<script src="dist/main.js" defer></script>
<div id="root"></div>
src/index.tsx

Store.tsxcreateContextとしているので、
StoreProviderの子コンポーネントのAppとその入れ子のコンポーネントでuseContextとすると、StoreProviderからのcontextを受け取れます。

import * as React from "react";
import { render } from "react-dom";
import App from "./components/App";
import { StoreProvider } from "./components/Store";

render(
  <StoreProvider>
    <App />
  </StoreProvider>,
  document.getElementById("root")
);
src/components/App.tsx

Todoリストの大枠のコンポーネント

import * as React from "react";
import AddTodo from "./AddTodo";
import TodoList from "./TodoList";
import Footer from "./Footer";

const App: React.FC = (): JSX.Element => {
  return (
    <>
      <AddTodo />
      <TodoList />
      <Footer />
    </>
  );
};

export default App;
src/interface.ts

いろんなファイルで共通の型定義はinterface.tsにまとめました。
ここ以外で、1箇所しか使わないような型定義は、それぞれのファイルにそのまま書いてます。

export interface ITodoState {
  id: number;
  text: string;
  completed: boolean;
}

export type TFilters = "SHOW_ALL" | "SHOW_COMPLETED" | "SHOW_ACTIVE";

export interface IState {
  todos: ITodoState[];
  visibilityFilter: TFilters;
  nextTodoId: number;
}

interface IAddTodoAction {
  type: "ADD_TODO";
  payload: { id: number; text: string };
}
interface IToggleTodoAction {
  type: "TOGGLE_TODO";
  payload: { id: number };
}
interface ISetVisibilityFilterAction {
  type: "SET_VISIBILITY_FILTER";
  payload: { filter: TFilters };
}
export type IActions =
  | IAddTodoAction
  | IToggleTodoAction
  | ISetVisibilityFilterAction;

export interface IStoreProvider {
  state: IState;
  dispatch: React.Dispatch<IActions>;
}
src/reducer.ts

dispatchを実行したときこの関数が実行され、新しいstateにする部分です。
reduxのcombineReducersを使っていないので、全てのstateをこの関数で管理しています。

import { IState, IActions } from "./interface";

export const reducer = (state: IState, action: IActions): IState => {
  switch (action.type) {
    case "ADD_TODO":
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: action.payload.id,
            text: action.payload.text,
            completed: false
          }
        ],
        nextTodoId: state.nextTodoId + 1
      };
    case "TOGGLE_TODO":
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    case "SET_VISIBILITY_FILTER":
      return {
        ...state,
        visibilityFilter: action.payload.filter
      };
    default:
      return state;
  }
};
src/components/Store.tsx

ここでuseReducerとして、state, dispatchを作成。
Providerのvalueに渡し、いろんなコンポーネントでuseContextとしてstate, dispatchを使いまわします。

import * as React from "react";
import { IState, IStoreProvider } from "../interface";
import { reducer } from "../reducer";

const initialState: IState = {
  todos: [],
  visibilityFilter: "SHOW_ALL",
  nextTodoId: 0
};

export const Store = React.createContext<IState | any>(initialState);

export const StoreProvider: React.FC = ({ children }): JSX.Element => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  return (
    <Store.Provider value={{ state, dispatch }}>{children}</Store.Provider>
  );
};
// 悩みどころ
// ここでanyにせず、IStateのみの型指定にすると、
export const Store = React.createContext<IState | any>(initialState);

// ------- ↓↓↓↓↓ -------

export const Store = React.createContext<IState>(initialState);

// ここのStore.Providerで型エラーになってしまいます。
    <Store.Provider value={{ state, dispatch }}>{children}</Store.Provider>

// createContextのジェネリクスでanyにせず良いやり方があれば教えていただきたいですm(_ _)m
src/components/AddTodo.tsx

Todoのリストに追加する処理です。

import * as React from "react";
import { Store } from "./Store";
import { IStoreProvider } from "../interface";

const AddTodo: React.FC = (): JSX.Element => {
  const { state, dispatch }: IStoreProvider = React.useContext(Store);

  const [value, setValue] = React.useState<string>("");

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!value.trim()) return;
    dispatch({
      type: "ADD_TODO",
      payload: {
        id: state.nextTodoId,
        text: value.trim()
      }
    });
    setValue("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={value} onChange={e => setValue(e.target.value)} />
      <button type="submit">Add Todo</button>
    </form>
  );
};

export default AddTodo;
src/components/TodoList.tsx

Todoリストを表示する部分です。state.visibilityFilterの値によってリストをフィルターします。

import * as React from "react";
import { Store } from "./Store";
import { IStoreProvider, ITodoState, TFilters } from "../interface";
import Todo from "./Todo";

const TodoList: React.FC = (): JSX.Element => {
  const { state, dispatch }: IStoreProvider = React.useContext(Store);

  const handleClick = (id: number) => {
    dispatch({
      type: "TOGGLE_TODO",
      payload: { id }
    });
  };

  const getVisibleTodos = (todos: ITodoState[], filter: TFilters) => {
    switch (filter) {
      case "SHOW_ALL":
        return todos;
      case "SHOW_COMPLETED":
        return todos.filter((t: ITodoState) => t.completed);
      case "SHOW_ACTIVE":
        return todos.filter((t: ITodoState) => !t.completed);
      default:
        throw new Error("Unknown filter: " + filter);
    }
  };

  return (
    <ul>
      {getVisibleTodos(state.todos, state.visibilityFilter).map(
        (todo: ITodoState) => (
          <Todo key={todo.id} {...todo} onClick={() => handleClick(todo.id)} />
        )
      )}
    </ul>
  );
};

export default TodoList;
src/components/Todo.tsx

todoの一つ一つのコンポーネントです。

TodoList.tsxから渡されるonClickが実行されると、dispatchが実行されてstateが更新されます。

import * as React from "react";

interface ITodoProps {
  completed: boolean;
  text: string;
  onClick: () => void;
}
const Todo: React.FC<ITodoProps> = ({
  text,
  completed,
  onClick
}): JSX.Element => {
  return (
    <li
      onClick={onClick}
      style={{
        textDecoration: completed ? "line-through" : "none"
      }}
    >
      {text}
    </li>
  );
};

export default Todo;
src/components/Footer.tsx

フィルターのボタンを配置している部分です。

import * as React from "react";
import { Store } from "./Store";
import { IStoreProvider, TFilters } from "../interface";
import FilterLink from "./FilterLink";

const Footer: React.FC = (): JSX.Element => {
  const { state, dispatch }: IStoreProvider = React.useContext(Store);

  const handleClick = (filter: TFilters): void => {
    dispatch({
      type: "SET_VISIBILITY_FILTER",
      payload: { filter }
    });
  };

  const getIsActive = (filter: TFilters): boolean => {
    return state.visibilityFilter === filter;
  };

  return (
    <>
      <span>Show:</span>
      <FilterLink {...{ handleClick, getIsActive, filter: "SHOW_ALL" }}>
        ALL
      </FilterLink>
      <FilterLink {...{ handleClick, getIsActive, filter: "SHOW_ACTIVE" }}>
        Active
      </FilterLink>
      <FilterLink {...{ handleClick, getIsActive, filter: "SHOW_COMPLETED" }}>
        Completed
      </FilterLink>
    </>
  );
};

export default Footer;
// このようにpropsを渡している部分は、
<FilterLink {...{ handleClick, getIsActive, filter: "SHOW_ALL" }}>
    ALL
</FilterLink>

// こういう風にpropsを渡すのと同じに意味になります
<FilterLink handleClick={handleClick} getIsActive={getIsActive} filter="SHOW_ALL">
    ALL
</FilterLink>
src/components/FilterLink.tsx

フィルターをかけるボタンの部分。

TodoList.tsxから渡されるhandleClickが実行されると、dispatchが実行されてstateが更新されます。

import * as React from "react";
import { TFilters } from "../interface";

interface ILinkProps {
  handleClick: (filter: TFilters) => void;
  getIsActive: (filter: TFilters) => boolean;
  filter: TFilters;
}
const Link: React.FC<ILinkProps> = ({
  children,
  handleClick,
  getIsActive,
  filter
}): JSX.Element => {
  return (
    <button onClick={() => handleClick(filter)} disabled={getIsActive(filter)}>
      {children}
    </button>
  );
};

export default Link;

yzYFmq6hR3.gif

ReactのHooksを使ってTodoリストを動かすことができました。最後まで読んでいただいてありがとうございましたm(_ _)m

この記事を書くのに使ったライブラリ、ツールなど

今回のサンプルで使用させていただいたライブラリなどは以下です。

よいと思った方はgithubで:star2:をつけるとよい思います。


この記事の文章はtextlinttextlint-rule-preset-japaneseを使ってLintさせていただきました。m(_ _)m

良いと思った方はgithubで:star2:をつけるとよい思います。

okumurakengo
人が作ってくれたご飯食べるときに何も言わずに食べるのは、ちょっとダメらしいという話を聞いたことがあるので、「あ、うめ、あ、うめ」って言いながら食ってたら、すごい変な人と思われてしまってしまった/初心者です、あまりわかっていません
https://bokete.jp/user/okumurakengo
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした