Tags: React

React Hooks でクラスを書き換える

React で無限スクロールを自力実装したくて調べたら React Hooks を使っているサンプルが出てきたので、まず React Hooks を習得するためにクラスを書き換える。

開発環境

  • node v16.6.0
  • npm v7.19.1
  • react v17.0.2

本文

ソースコードは 先週の記事 のコードを使い、下記の Main というクラスを React Hooks を使って書き換えることにする。

ついでに「次へ」ボタンを押下したときページングするのではなく、既に画面上に表示されているものの下に新しく読み込んだものを追加するようにする。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
import React from 'react';
import './App.css';
import firebase from "firebase/app";
import "firebase/firestore";
import dayjs from 'dayjs';

const firebaseConfig = {
  // 自分のプロジェクトの内容に差し替える
};

firebase.initializeApp(firebaseConfig);

const db = firebase.firestore();

function Blog(props) {
  const items = props.items;
  return (
    <ol>
      {items.map((item) => {
        const created = dayjs(item.created.toDate());
        const updated = dayjs(item.updated.toDate());
        return (
          <li key={item.id}>
            <div>{item.id}</div>
            <div className="App-text">{item.text}</div>
            <div>
              <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                {created.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
            <div>
              <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                {updated.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function NextButton(props) {
  const hasNext = props.hasNext;
  if (hasNext) {
    return <button onClick={() => props.onClick()}>次へ</button>;
  }
  return null;
}

class Main extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      items: [],
      lastItem: null,
      hasNext: false
    };
  }

  async getItems() {
    const items = [];
    const lastItem = this.state.lastItem;
    const hasNext = this.state.hasNext;
    let first;
    if (hasNext) {
      first = await db.collection("posts").orderBy('created', 'desc').startAfter(lastItem.data().created).limit(5);
    } else {
      first = await db.collection("posts").orderBy('created', 'desc').limit(5);
    }
    const snapshot = await first.get();
    const last = snapshot.docs[snapshot.docs.length - 1];
    const next = await db.collection("posts").orderBy('created', 'desc').startAfter(last.data().created).limit(1);
    const nextSnapshot = await next.get();
    snapshot.forEach((doc) => {
      let item = doc.data();
      item.id = doc.id;
      items.push(item);
    });
    this.setState({
      items: items,
      lastItem: last,
      hasNext: nextSnapshot.docs.length > 0 ? true : false
    });
  }

  async componentDidMount() {
    await this.getItems();
  }

  render() {
    const items = this.state.items;
    const hasNext = this.state.hasNext;
    return (
      <main>
        <Blog items={items}/>
        <NextButton hasNext={hasNext} onClick={() => this.getItems()}/>
      </main>
    );
  }
}

function App() {
  return (
    <div>
      <Main />
    </div>
  );
}

export default App;

ステートフックへの書き換え

React 公式サイトの ステートフックの利用法 を見て Main クラスを書き換えていく。

一旦データの取得・更新処理は除外し、

  • useState を import
  • class Main を function Main に変更
  • state の宣言を ステートフックに変更

とすると下記のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import React, { useState } from 'react';
import './App.css';
import firebase from "firebase/app";
import "firebase/firestore";
import dayjs from 'dayjs';

const firebaseConfig = {
  // 自分のプロジェクトの内容に差し替える
};

firebase.initializeApp(firebaseConfig);

const db = firebase.firestore();

function Blog(props) {
  const items = props.items;
  return (
    <ol>
      {items.map((item) => {
        const created = dayjs(item.created.toDate());
        const updated = dayjs(item.updated.toDate());
        return (
          <li key={item.id}>
            <div>{item.id}</div>
            <div className="App-text">{item.text}</div>
            <div>
              <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                {created.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
            <div>
              <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                {updated.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function NextButton(props) {
  const hasNext = props.hasNext;
  if (hasNext) {
    return <button onClick={() => props.onClick()}>次へ</button>;
  }
  return null;
}

function Main() {
  const [items, setItems] = useState([]);
  const [lastItem, setLastItem] = useState(null);
  const [hasNext, setHasNext] = useState(false);

  return (
    <main>
      <Blog items={items} />
      <NextButton hasNext={hasNext} onClick={() => alert("clicked!")}/>
    </main>
  );
}

function App() {
  return (
    <div>
      <Main />
    </div>
  );
}

export default App;

データの取得処理を書いていないのでレンダリングされる画面は真っ白だが、まずこれでコンパイルは通る状態になる。

クラスを使っている場合は this.state.items のようにして state にアクセスしなければならないが、ステートフックを使っている場合は単に items と書くと state にアクセスできる。

副作用フックへの書き換え

次にデータの取得周りを副作用フックに書き換える。

副作用フックの利用法 を参考に

  • useEffect を import
  • useEffect 内にデータ読み込み処理を追加
  • loadNext という state を追加し、loadNext が true の場合のみデータ読み込みを行うようにする

という書き換えを行った。

loadNext という state を追加したのは、依存している state の内のいずれかが変更されると useEffect が発火してしまう動きとなり、無限に読み込みが発生してエラーになったからである。
(つまり items を useEffect 内で更新すると、それによって再度 useEffect が発火してしまう。)

また async 関数を useEffect 内に定義する書き方については、間違った書き方をしたら React のコンパイラが書き方を教えてくれたのでそれに従った。たぶんこの書き方で正しい。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
import React, { useState, useEffect } from 'react';
import './App.css';
import firebase from "firebase/app";
import "firebase/firestore";
import dayjs from 'dayjs';

const firebaseConfig = {
  // 自分のプロジェクトの内容に差し替える
};

firebase.initializeApp(firebaseConfig);

const db = firebase.firestore();

function Blog(props) {
  const items = props.items;
  return (
    <ol>
      {items.map((item) => {
        const created = dayjs(item.created.toDate());
        const updated = dayjs(item.updated.toDate());
        return (
          <li key={item.id}>
            <div>{item.id}</div>
            <div className="App-text">{item.text}</div>
            <div>
              <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                {created.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
            <div>
              <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                {updated.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function NextButton(props) {
  const hasNext = props.hasNext;
  if (hasNext) {
    return <button onClick={() => props.onClick()}>次へ</button>;
  }
  return null;
}

function Main() {
  const [items, setItems] = useState([]);
  const [lastItem, setLastItem] = useState(null);
  const [hasNext, setHasNext] = useState(false);
  const [loadNext, setLoadNext] = useState(true);

  useEffect(() => {
    async function getItems() {
      let first;
      if (hasNext) {
        first = await db.collection("posts").orderBy('created', 'desc').startAfter(lastItem.data().created).limit(5);
      } else {
        first = await db.collection("posts").orderBy('created', 'desc').limit(5);
      }
      const snapshot = await first.get();
      const last = snapshot.docs[snapshot.docs.length - 1];
      const next = await db.collection("posts").orderBy('created', 'desc').startAfter(last.data().created).limit(1);
      const nextSnapshot = await next.get();
      snapshot.forEach((doc) => {
        let item = doc.data();
        item.id = doc.id;
        items.push(item);
      });
      setItems(items);
      setLastItem(last);
      setHasNext(nextSnapshot.docs.length > 0 ? true : false);
    }
    if (loadNext) {
      getItems();
      setLoadNext(false);
    }
  }, [items, lastItem, hasNext, loadNext]);

  return (
    <main>
      <Blog items={items} />
      <NextButton hasNext={hasNext} onClick={() => alert("clicked!")}/>
    </main>
  );
}

function App() {
  return (
    <div>
      <Main />
    </div>
  );
}

export default App;

これで画面上には最初の 5 件のデータが出て、「次へ」ボタンを押すと “clicked!” というアラートが表示される状態となった。

「次へ」ボタン押下時の処理

最後に「次へ」ボタン押下時の処理だが、先に述べたように useEffect は依存している state が更新されると発火されるので、「次へ」ボタン押下時に loadNext を true に更新することによって次のデータを読み込める。

 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
// 50行目まで省略
function Main() {
  const [items, setItems] = useState([]);
  const [lastItem, setLastItem] = useState(null);
  const [hasNext, setHasNext] = useState(false);
  const [loadNext, setLoadNext] = useState(true);

  useEffect(() => {
    async function getItems() {
      let first;
      if (hasNext) {
        first = await db.collection("posts").orderBy('created', 'desc').startAfter(lastItem.data().created).limit(5);
      } else {
        first = await db.collection("posts").orderBy('created', 'desc').limit(5);
      }
      const snapshot = await first.get();
      const last = snapshot.docs[snapshot.docs.length - 1];
      const next = await db.collection("posts").orderBy('created', 'desc').startAfter(last.data().created).limit(1);
      const nextSnapshot = await next.get();
      snapshot.forEach((doc) => {
        let item = doc.data();
        item.id = doc.id;
        items.push(item);
      });
      setItems(items);
      setLastItem(last);
      setHasNext(nextSnapshot.docs.length > 0 ? true : false);
    }
    if (loadNext) {
      getItems();
      setLoadNext(false);
    }
  }, [items, lastItem, hasNext, loadNext]);

  return (
    <main>
      <Blog items={items} />
      <NextButton hasNext={hasNext} onClick={() => setLoadNext(true)}/>
    </main>
  );
}

function App() {
  return (
    <div>
      <Main />
    </div>
  );
}

export default App;

これで「次へ」ボタンを押下すると続きのデータが画面に追加されるようになる。

今回はここまで。