ReactでTodoアプリをつくる

それでは、前章まででReactの基礎を紹介しましたので、今回は早速 Create React App で作成した雛形をもとに、サンプルアプリの定番中の定番である TODOアプリ をつくっていこうと思います。

目次

Todoアプリ完成イメージの確認

右下の「+」ボタンをクリックするとダイアログが立ち上がり、タイトルを入力して「Add」ボタンをクリックすることで、TODOが追加されていきます。CodeSandboxでコードを編集できるので、良ければ確認しながら試してみてください。

CodeSandboxでTodoアプリのコードを編集する

ディレクトリ構成

Github

追加ライブラリのインストール

今回、スタイリングにCSSやstyle属性ではなく styled-components というライブラリを使いますので、styled-componentsを下記のコマンドでインストールします。

npm install --save styled-components

styled-componentsVendor Prefixなどを自動で付けてくれ、コンポーネントを使いまわしやすくしてくれる便利なライブラリです。今回はこの styled-components を使ってReactコンポーネントのスタイリングを行っていきます。

Reactアプリのエントリーポイント (index.js)

ではReactアプリのエントリーポイントであるindex.jsから見ていきます。

/* index.js */

import React from "react";
import ReactDOM from "react-dom";

// styled-componentsをim_port
import styled, { createGlobalStyle } from "styled-components";

// アプリ全体のStateを管理するTodoContextをインポート
import { TodoContextProvider } from "./TodoContext";

// Todoを追加するためのダイアログコンポーネント
import { TodoInputDialog } from "./TodoInputDialog";

// Todoを表示するためのTODOリスト
import { TodoList } from "./TodoList";

// ダイアログ表示のためのボタン
import { AddTodoBtn } from "./AddTodoBtn";

// styled-componentsでスタイル付きのdiv要素をコンポーネントとして定義
const AppWrappper = styled.div`
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  height: 100%;
  margin: 20px auto;
  max-width: 500px;
  padding: 20px 40px;
  position: relative;
  width: 100%;
`;

// styled-componentsでスタイル付きのh1要素をコンポーネントとして定義
const HeaderTitle = styled.h1`
  font-size: 20px;
  font-weght: normal;
  color: #009688;
  margin-bottom: 32px;
`;

// styled-componentsでグローバルなに展開されるCSSを定義。
const GlobalStyle = createGlobalStyle`
  html,body {
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
  }

  main {
    display: block;
  }
`;

const App = props => (
  <AppWrappper>
    {/* styled-componentsでCSSをグローバルな名前空間に出力する。 */}
    <GlobalStyle />
    <header>
      <HeaderTitle>TodoApp</HeaderTitle>
    </header>
    <main>
      {/* 
        TodoContext.jsx内部で定義しているProviderで囲む。
        Providerで囲むことにより
        子孫コンポーネントで、
        Consumerを通じたデータの購読が可能となる。
       */}
      <TodoContextProvider>
        {/* ダイアログ表示用のボタン */}
        <AddTodoBtn />
        {/* Todo表示用のリスト */}
        <TodoList />
        {/* Todo入力用のダイアログ */}
        <TodoInputDialog />
      </TodoContextProvider>
    </main>
  </AppWrappper>
);

// idがrootである要素にReactアプリをマウントする。
ReactDOM.render(<App />, document.getElementById("root"));

styled-componentsを使うことで、下記のような形式でスタイル付きのReactコンポーネントが定義できるようになります。また、コンポーネントとして用いずに、グローバルな名前空間にCSSを出力することもできます。

// スタイル付きのコンポーネントを定義する
const AppWrappper = styled.div`
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  height: 100%;
  margin: 20px auto;
  max-width: 500px;
  padding: 20px 40px;
  position: relative;
  width: 100%;
`;

// styled-componentsで定義したコンポーネントを使用する。
<AppWrapper>...</AppWrapper>

言ってしまえば、index.jsで行っていることは各種コンポーネントをインポートして、それをレンダリングしているのみになります。

const App = props => (
 <AppWrappper>
    {/* styled-componentsでCSSをグローバルな名前空間に出力する。 */}
    <GlobalStyle />
    <header>
      <HeaderTitle>TodoApp</HeaderTitle>
    </header>
    <main>
      {/* 
        TodoContext.jsx内部で定義しているProviderで囲む。
        Providerで囲むことにより
        子孫コンポーネントで、
        Consumerを通じたデータの購読が可能となる。
       */}
      <TodoContextProvider>
        {/* ダイアログ表示用のボタン */}
        <AddTodoBtn />
        {/* Todo表示用のリスト */}
        <TodoList />
        {/* Todo入力用のダイアログ */}
        <TodoInputDialog />
      </TodoContextProvider>
    </main>
  </AppWrappper>
);

それでは各種コンポーネントを見ていきましょう。まずはアプリ全体のStateを管理しているTodoContextからです。

ReactアプリのState管理 (TocdoContext.jsx)

TodoContext.jsxはReactアプリのStateを管理します。内部的には以前の章で紹介した Context API を用いています。

import React from "react";

// 各TodoタスクのIDを定義。Todoを追加するごとにカウントがインクリメントされます。
let idCount = 1;

// React.creeateContexxtでコンテキストを作成
const TodoContext = React.createContext();

/* 
  ContextからConsumerを生成してエクスポート。
  このConsumerを介して、Providerの子孫コンポーネントでデータを購読する。
*/
export const TodoConsumer = TodoContext.Consumer;

/* 
  TodoContextProviderを定義する。
  このコンポーネントの子孫コンポーネントではTodoConsumerを介して
  データ (this.state) を購読することができる。
*/
export class TodoContextProvider extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      /* ◊alues */

      // todoタスク
      todos: [
        {
          id: idCount,
          title: "1st Todo"
        }
      ],

      // todo追加用のダイアログが表示されているかどうか
      todoInputDialogShow: false,

      // todo追加用のダイアログに入力されている文字列
      todoTitleInput: "",

      /* Methods */

      // todoの追加処理するメソッド
      addTodo: this.addTodo,

      // todoの削除処理するメソッド
      deleteTodo: this.deleteTodo,

      // ダイアログの表示を切り替えるメソッド
      toggleDialog: this.toggleDialog,

      // todo追加用ダイアログ内部のテキスト入力の値を更新するメソッド
      updateTitleInput: this.updateTitleInput
    };
  }

  // 自身のstateに新しいtodoを追加する。
  addTodo = ({ title }) => {
    // {...}はスプレッド構文
    // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax
    this.setState({
      todos: [
        ...this.state.todos,
        {
          id: (idCount += 1),
          title
        }
      ]
    });
  };

  // idで指定したtodoをstateの中から削除する。
  deleteTodo = id => {
    // filterメソッドについてはこちら
    // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
    this.setState({
      todos: this.state.todos.filter(todo => todo.id !== id)
    });
  };

  // todo追加用のダイアログ表示を切り替える。
  toggleDialog = () => {
    this.setState({
      todoInputDialogShow: !this.state.todoInputDialogShow
    });
  };

  // todo追加用のダイアログ中の入力テキストを更新する。
  updateTitleInput = text => {
    this.setState({
      todoTitleInput: text
    });
  };

  render() {
    return (
      <TodoContext.Provider value={this.state}>
        {/* Contextから作成されたProviderのvalueに自身のstateをセットすることで、 */}
        {/* 子孫コンポーネントでstateを読めるようにする。 */}
        {/* this.props.childrenを返すことで、渡された子孫コンポーネントをレンダリング */}
        {this.props.children}
      </TodoContext.Provider>
    );
  }
}

まずは React.createContext()でコンテキストを作成します。

// React.creeateContexxtでコンテキストを作成
const TodoContext = React.createContext();

そして、コンテキストから生成したConsumerはエクスポートして、他のコンポーネントで使用できるようにします。

/* 
  ContextからConsumerを生成してエクスポート。
  このConsumerを介して、Providerの子孫コンポーネントでデータを購読する。
*/
export const TodoConsumer = TodoContext.Consumer;

TodoContextProviderコンポーネントでStateを定義します。

export class TodoContextProvider extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
        ...
    }
 }
 
 ...
}

TodoContextProviderコンポーネント内部でコンテキストから作成したProviderを間に挟んで、自身の子孫コンポーネントをレンダリングするようにします。

  render() {
    return (
      <TodoContext.Provider value={this.state}>
        {/* Contextから作成されたProviderのvalueに自身のstateをセットすることで、 */}
        {/* 子孫コンポーネントでstateを読めるようにする。 */}
        {/* this.props.childrenを返すことで、渡された子孫コンポーネントをレンダリング */}
        {this.props.children}
      </TodoContext.Provider>
    );
  }

エクスポートしたTodoContextProviderをindex.jsで下記のように用いることで子孫コンポーネントでのデータ購読を可能にします。直下の子孫コンポーネント (children) は「AddTodoBtn」、「TodoList」、「TodoInputDialog」の3つです。それぞれのコンポーネントの子孫コンポーネントでもConsumerを使ってデータにアクセスできます。

/* index.js */

<TodoContextProvider>
    <AddTodoBtn />
    <TodoList />
    <TodoInputDialog />
</TodoContextProvider>

それでは子孫コンポーネントである3つのコンポーネントを見ていきましょう。

ダイアログの表示切り替えボタン (AddTodoBtn.jsx)

このコンポーネントが行うことは「ボタンがクリックされた時にTodo追加用のダイアログの表示を切り替えることのみです」

import React from "react";
import styled from "styled-components";

import { TodoConsumer } from "./TodoContext.jsx";

const Btn = styled.button`
  background-color: #009688;
  border: 0;
  border-radius: 50%;
  bottom: 20px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  color: #fafafa;
  cursor: pointer;
  display: block;
  height: 50px
  font-size: 18px;
  margin: 0 auto;
  outline: none;
  position: fixed;
  right: 20px;
  transition-duration: 0.2s;
  width: 50px;
  
  &:hover {
    opacity: 0.8;
  }
`;

export const AddTodoBtn = () => (
  /*TodoConsumerで囲むことで、TodoContextで定義しているStateを購読できる。 */
  <TodoConsumer>
    {val => (
      <Btn
        onClick={() => {
          /* ボタンがクリックされたときにはダイアログの表示を切り替える。 */
          val.toggleDialog();
        }}
      >
        {/*https://github.com/google/material-design-icons/blob/master/LICENSE */}
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill={"#fff"}
        >
          <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
        </svg>
      </Btn>
    )}
  </TodoConsumer>
);

コンテキストから作成されたConsumerで囲うことによって、TodoContextProviderに設定されている value (state) がそのまま、関数の引数 (ここでは val ) として渡されてきます。val の中にはダイアログの表示を切り替える関数も含まれているので、TodoContext.jsxで定義した toggleDialog() を使ってダイアログの表示を切り替えます。Reactでは通常のDOM要素で行うのと同じような感覚で、clickイベントやchangeイベントに関数を渡して、ハンドリングすることができ、ここではBtn要素がクリックされた時に、ダイアログの表示のON・OFFを切り替えます。

<TodoConsumer>
    {val => (
      <Btn
        onClick={() => {
          val.toggleDialog();
        }}
      >
         ...
      </Btn>
    )}
  </TodoConsumer>

次は表示するダイアログである、TodoInputDialog.jsxを見ていきます。

Todoを追加する (TodoInputDialog.jsx)

import React from "react";
import styled from "styled-components";

// ConsumerをTodoContext.jsxからインポート
import { TodoConsumer } from "./TodoContext.jsx";

const Dialog = styled.div`
  background-color: #fff;
  bottom: 0;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  left: 0;
  padding: 12px;
  position: absolute;
  transition-duration: 0.2s;
  transition-timing-function: ease-in-out;
  right: 0;
  top: 0;
  width: 300px;
  height: 300px;
  z-index: 1000;
  margin: 20px auto;

  /* 
    Dialogコンポーネントに渡されたpropsのopenがtrueかfalseによってスタイルを切り替える。 
    openがtrueの場合は表示。falseの場合は非表示とする。  
  */
  ${props =>
    props.open
      ? `
    opacity: 1;
    pointer-events: auto;
    transform: scale(1, 1);
  `
      : `
    opacity: 0;
    pointer-events: none;
    transform: scale(0, 0);
  `}
`;

const DialogTitle = styled.h2`
  font-size: 18px;
  text-align: center;
`;

// styled-componentsでは sttrs()関数によって属性を追加できる。
const DialogTitleInput = styled.input.attrs({ type: "text" })`
  border: 1px solid #ccc;
  border-radius: 4px;
  display: block;
  margin: 0 auto 40px;
  padding: 8px;
  outline: none;
`;

const DialogBtn = styled.button`
  background-color: #009688;
  border: 0;
  border-radius: 6px;
  color: #fafafa;
  cursor: pointer;
  display: block;
  font-size: 18px;
  margin: 0 auto;
  outline: none;
  padding: 12px 0;
  transition-duration: 0.2s;
  width: 200px;

  &:hover {
    opacity: 0.8;
  }
`;

const Svg = styled.svg`
  position: absolute;
  top: -8px;
  right: -8px;
`;

const DialogCancelBtn = props => (
  // https://github.com/google/material-design-icons/blob/master/LICENSE
  <Svg
    xmlns="http://www.w3.org/2000/svg"
    width="24"
    height="24"
    viewBox="0 0 24 24"
    onClick={props.onClick}
  >
    <path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z" />
  </Svg>
);

export const TodoInputDialog = props => (
  /*TodoConsumerで囲むことで、TodoContextで定義しているStateを購読できる。 */
  <TodoConsumer>
    {val => (
      /* openをpropsとしてDialogコンポーネントに渡すことで表示を切り替える。 */
      <Dialog open={val.todoInputDialogShow}>
        <DialogTitle>Add Todo</DialogTitle>
        <DialogTitleInput
          onChange={e => {
            /* changeイベントをハンドリングして、自身のvalueをContext API経由で更新する。 */
            val.updateTitleInput(e.currentTarget.value);
          }}
          value={val.todoTitleInput}
        />
        <DialogBtn
          onClick={() => {
            if (val.todoTitleInput !== "") {
              /* 入力されている文字が空でなければ、Todoを追加する。 */
              val.addTodo({ title: val.todoTitleInput });
              val.toggleDialog();
              val.updateTitleInput("");
            }
          }}
        >
          Add
        </DialogBtn>
        <DialogCancelBtn
          onClick={() => {
            val.toggleDialog();
          }}
        />
      </Dialog>
    )}
  </TodoConsumer>
);

styled-components内部で渡されてきたProps中のopenが、「trueであるか、falseであるか」に応じて、ダイアログの表示を切り替えます。todoInputDialogShowという変数がそれであり、その状態はTodoContext.jsxで管理されています。ここでも Consumerを経由してProviderの値を購読することでReactアプリのデータにアクセスしています。

  /* 
    Dialogコンポーネントに渡されたpropsのopenがtrueかfalseによってスタイルを切り替える。 
    openがtrueの場合は表示。falseの場合は非表示とする。  
  */
  ${props =>
    props.open
      ? `
    opacity: 1;
    pointer-events: auto;
    transform: scale(1, 1);
  `
      : `
    opacity: 0;
    pointer-events: none;
    transform: scale(0, 0);
  `}
`;

...


/* TodoConsumerで囲むことで、TodoContextで定義しているStateを購読できる。 */
<TodoConsumer>
    {val => (
        <Dialog open={val.todoInputDialogShow}>
         .......
        </Dialog>
    )}
</TodoConsumer>

Todoをリスト表示する (TodoList.jsx)

import React from "react";
import styled from "styled-components";

// TodoContextからTodoConSumerをインポート
import { TodoConsumer } from "./TodoContext";

const ListItemWrapper = styled.div`
  margin-bottom: 40px;
`;

const ListItem = styled.li`
  align-items: center;
  border: 1px solid #ccc;
  border-top: 0;
  display: flex;
  justify-content: space-between;
  list-style: none;
  padding: 12px 24px;

  /* firstがtrueかfalseでスタイルを切り替える。 */
  ${props => (props.first ? "border-top: 1px solid #ccc;" : "")}
`;

const CloseIcon = props => (
  // https://github.com/google/material-design-icons/blob/master/LICENSE
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="24"
    height="24"
    viewBox="0 0 24 24"
    onClick={props.onClick}
  >
    <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
  </svg>
);

const isFirst = index => index === 0;

const ListItems = props => {
  /* 
    Stateに渡されたtodos (配列) をmap()でループさせて
    <ListItem>を複数レンダリングする。

    https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map
  */
  return props.todos.map((todo, index) => (
    /* React要素をmap()でループさせるとき (Virtual DOM) 描画時の差分計算のためユニークなkeyを指定する */
    <ListItem key={todo.id} first={isFirst(index)}>
      {todo.title}
      <CloseIcon
        onClick={() => {
          // 指定したIDを持つTodoをTodoリストから削除する。
          props.deleteTodo(todo.id);
        }}
      />
    </ListItem>
  ));
};

export const TodoList = () => (
  /*TodoConsumerで囲むことで、TodoContextで定義しているStateを購読できる。 */
  <TodoConsumer>
    {val => (
      <ListItemWrapper>
        {/* valをpropsとしてそのままListItemsに渡す。 */}
        <ListItems {...val} />
      </ListItemWrapper>
    )}
  </TodoConsumer>
);

Context APIのConsumerから渡されてきた val をそのままListItemsコンポーネントに渡しています。

 <TodoConsumer>
    {val => (
        <ListItems {...val} />    
    )}
 </TodoConsumer>

そして、ListItems内部ではArray.prototype.map() を使ってListItemコンポーネントをループさせて、レンダリングを行っています。基本的にArray.prototype.map()などでループでReactコンポーネントをレンダリングするときには  key という属性が必要です。このkeyに重複しないユニークな値をセットすることでDOM (Virtual DOM) 更新時のパフォーマンスが向上します。ここではTodoを作成する時に生成している id の値を渡しています。

    <ListItem key={todo.id} first={isFirst(index)}>
      {todo.title}
      <CloseIcon
        onClick={() => {
          // 指定したIDを持つTodoをTodoリストから削除する。
          props.deleteTodo(todo.id);
        }}
      />
    </ListItem>

これで一通り、本当にシンプルな機能ですがTodoアプリを完成させることができました。次はReactコンポーネントのテストを書いていきます。