Open18

Vueユーザーが真剣にReactにチャレンジする学習メモ

ここのえここのえ

概要

Vueを普段使っているのですが、いい加減Reactをちゃんと勉強しておかなければ…という気持ちになりチャレンジしていました。Vueユーザ目線でのReact学習録です。

分量がある程度増えたら記事にしたいかも。

ターゲット

  • Vue 3 Composition APIを普段使っている(もしくはある程度把握している)
  • React Hooksを使ったReactのコーディングに興味がある
  • Typescriptを使う

対象外

  • Hooks以前のReact(そもそも知らないので)
  • Next.js関連
ここのえここのえ

Reactとは? [1]

ReactはUI構築のためのJavascriptライブラリ。Meta(旧Facebook)が中心となり開発している。

  • 宣言的(Declarative)View によって、予測可能なコードでシンプル、デバッグが行いやすい。
  • コンポーネントベース(Component-Based) であり、カプセル化されたコンポーネントでステートを管理しているため、複雑なUIを作ることが容易である
  • Learn once, Write Anywhere であり、Reactのコードを様々な技術スタックで使用可能(React Nativeなど)。

ReactはSimple、VueはEasyという言説をよく見かけるが、これがどこから言われ始めたのか、調べても発見できなかった…

脚注
  1. https://github.com/facebook/react/blob/main/README.md ↩︎

ここのえここのえ

JSXの概要

Vueの .vue ファイルのように、Reactでは .jsx を使用する。
Typescriptの場合は .tsx

JSXは Javascript XML の略で、React用のJavascript拡張構文。
HTMLとJavascriptのハイブリッドのような形式になっている。

.vue との大きな違い

根本的に違う点として、.vueはVueコンポーネントを記述するが、.jsxはJavascriptの拡張としてHTMLを取り扱う、という点。HTML関連の要素は関数の返り値として上位コンポーネントに渡す。

function App() {
  return (
    <>
      <p>hello :)</p>
    </>
  );
}

最上位ではReactDOM.createRoot().render() で実際にDOMをrenderする。

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
ここのえここのえ

JSXの基本の書き方

React v16.8 以降の React Hooks に基づく。
Hook時代のReactはそれ以前と異なり、クラスを作らず関数ベースでコンポーネントを取り扱う。
(オブジェクト指向ベースではなく、関数ベース風のスタイルに変更された[1])


コンポーネント名

PascalCaseで命名する。
Vueと異なり、ケバブケースは容認されていない(ファイル名ではなく、functionの名前として命名するため当然ではあるが…)

  • React: function Sample() { ... } (関数名)
  • Vue: Sample.vue (ファイル名)

HTMLの記述

Reactは 関数の返り値 として渡す。

Sample.jsx
function Sample() {
  return (
    <p>hoge fuga</p>
  );
}

Vueは <template>内に記述するので、この時点でだいぶ根幹から違う。

Sample.vue
<template>
<p>foo bar</p>
</template>

CSSの記述

ReactのJSXはあくまでJavascript拡張構文なので、標準でCSSを書くような機能は存在しない。
幾つかソリューションは存在する[2][3][4]ようだが、どれを使うのかはまちまちのように見える。

1. CSS Modules (CSS)

恐らく最もメジャー?
foo.module.css にスタイルを記述して、コンポーネント内でそのcssをimportする方式。

  • Good👍
    • 普通のCSSを書ける
    • コンポーネント単位でローカルスコープになる
    • Reactでよく使われるフレームワーク、Next.jsで標準サポートされている
  • Bad😡
    • 近い将来に廃止予定であるとアナウンスされている[5]
    • コンポーネントの数だけmodule.cssが作られていくので、ファイル数が膨大になる
サンプルコード
Sample.jsx
import styles from "./Sample.module.css";

export const Sample = () => {
  return (
    <>
      <p className={styles.sample}>foo bar</p>
    </>
  );
};

Sample.module.css
.sample {
    font-weight: bold;
}

2. styled-components (CSS-in-JS)

スタイル付きのコンポーネントを生成し、それを使用してHTMLを組んでいく。
styled-components のインストールが必要。

  • Good👍
    • styled-componentでCSSの要素が定義されているので、IDEで補完が効く
  • Bad😡
    • return()節のHTMLを見てもパッと見で <h1>なのか<p>なのかといったHTMLタグがさっぱりわからなくなり、別コンポーネントを参照しているかどうかも判別できなくなる
サンプルコード
import { styled } from "styled-components";

export const Sample = () => {
  const SampleStyle = styled.p`
    font-weight: bold;
  `;

  return (
    <>
      <SampleStyle>foo bar</SampleStyle>
    </>
  );
};

3. vanilla-extract (CSS-in-TS...?)

エンジニア仲間の方に教えてもらったライブラリ。
styled-component と思想が異なり、スタイルそのものを取り扱う。
CSS+Typescriptの考え方が根幹にあり、テーマの作成やCSSの取り扱いが型安全に行える。
@vanilla-extract/css のインストールが必要。

  • Good👍
    • Typescriptフレンドリーで、型がちゃんとあるので補完も効く
    • JSX側はスタイルを当てるだけなので、ごちゃごちゃにならない
  • Bad😡
    • <filename>.css.ts ファイルに書かなければならないルールがあり、JSXの中でstyleを定義することはできないため、ファイル数は増える
サンプルコード
Sample.tsx
import { sampleStyle } from "./Sample.css.ts";

export const Sample = () => {
  return (
    <>
      <p className={sampleStyle}>foo bar</p>
    </>
  );
};
Sample.css.ts
import { style } from "@vanilla-extract/css";

export const sampleStyle = style({
  fontWeight: "bold",
});

その他

  • CSSフレームワークとして、 Tailwind CSSUnoCSS を使う
    • この辺りはあまりVueと大差がない
  • 普通に<head>でCSSを読み込ませる
    • ❌何のためにコンポーネント化したのか分からないので、やりたくはない
  • Kuma UI が少し気になる

Vueの場合

普通にCSS-in-JSができるため、特に考える必要はない

Sample.vue
<style scoped>
</style>

Javascript

Reactの場合、そもそもJSXがJavascriptの拡張構文なので、ただJSをそのまま書くだけ。

Sample.jsx
export const Sample = () => {
  const message = "foo bar"
  const test = () => {
    console.log("baz")
  }
  
  return (
    <>
      <p onClick={test}>{message}</p>
    </>
  );
};

Vueは <script> 節の中に書く。

Sample.vue
<script setup lang="ts">
</script>
脚注
  1. https://zenn.dev/ababup1192/articles/ebc5ff81f92a88 ↩︎

  2. https://zenn.dev/yuki_tu/articles/38c8df79522563 ↩︎

  3. https://zenn.dev/longbridge/articles/78b5018315c876 ↩︎

  4. https://zenn.dev/kazu1/articles/3f7c6b8b1d3479 ↩︎

  5. https://github.com/webpack-contrib/css-loader/issues/1050#issuecomment-592541379 ↩︎

ここのえここのえ

他コンポーネントの呼び出し

ReactもVueも同じ。importしてHTMLタグのようにして展開。

App.jsx
import { Sample } from "./Sample.tsx";

function App() {
  return (
    <>
      <Sample />
    </>
  );
}
App.vue
<script setup lang="ts">
import Sample from "./Sample.vue";
</script>

<template>
  <Sample />
</template>
ここのえここのえ

Typescript関係

この辺からTypescript周りも確認。
ReactのHTMLをreturnする関数の返り値は React.FC(React.FunctionComponent) 。

Sample.tsx
import { FC } from "react";

export const Sample: FC = () => {
  return (
    <>
    </>
  );
};
ここのえここのえ

変数をHTML内で展開

書くの忘れてた。

Reactは {foo} で展開する。

Sample.tsx
export const Sample: FC = () => {
  const hoge = "fuga"

  return (
    <>
      <p>{hoge}</p>
    </>
  );
};

Vueは {{foo}} で展開する。うっかり癖で二重にしそう。

Sample.vue
<template>
  <p>{{hoge}}</p>
</template>
ここのえここのえ

Props (変数渡し)

Vueとさほど変わらない。

Sample.tsx
import { FC } from "react";

type Props = {
  message: string;
};

export const Sample: FC<Props> = ({ message }) => {
  return (
    <>
      <p>{message}</p>
    </>
  );
};

App.tsx
import { Sample } from "./Components/Sample.tsx";

function App() {
  const text = "hi!"
  
  return (
    <>
      <Sample message={text}/>
    </>
  );
}

Vueの場合はこれ。
:messagev-bind で渡す。

Sample.vue
<script setup lang="ts">
const props = defineProps<{
  message: string
}>();
</script>

<template>
  <p>{{props.message}}</p>
</template>
App.vue
<script setup lang="ts">
import Sample from "./components/Sample.vue";
const msg = "hi!"
</script>

<template>
  <Sample :message="msg" />
</template>
ここのえここのえ

Props (関数渡し・引数なし)+ onClickの実装

例えばクリック時にアラートを出すような実装を行うなら、以下のように書く。
律儀に返り値のMouseEventも引っかけてみます。
ReactのonClickで返ってくる MouseEvent は、 Javascript標準のMouseEventではなく、React.MouseEvent なので型定義の際には注意が必要かも。

Sample.tsx
import { FC, MouseEventHandler } from "react";

type Props = {
  onClickButton: MouseEventHandler<HTMLButtonElement>;
};

export const Sample: FC<Props> = ({ onClickButton }) => {
  return (
    <>
      <button onClick={onClickButton}>Click Event</button>
    </>
  );
};
App.tsx
import { Sample } from "./Components/Sample.tsx";
import { MouseEvent } from "react";

function App() {
  const alertbox = (_: MouseEvent<HTMLButtonElement>) => {
    alert("foo bar baz!");
  };

  return (
    <>
      <Sample onClickButton={alertbox} />
    </>
  );
}

VueもpropsでFunction渡しがサポートされているので同じ感じ。
onClickの場合、v-on:clickで渡せる。
Reactと違い、Vueの返り値のMouseEventは React.MouseEvent のような独自クラスではなく、Javascriptの標準クラスを使用する。

Sample.vue
<script setup lang="ts">
const props = defineProps<{
  onClickButton: (event: MouseEvent) => void
}>();
</script>

<template>
  <button v-on:click="props.onClickButton()">Push!</button>
</template>
App.vue
<script setup lang="ts">
import Sample from "./components/Sample.vue";
const alertbox = (_: MouseEvent) => {
  alert("foo bar baz!")
}
</script>

<template>
  <Sample :on-click-button="alertbox" />
</template>
脚注
  1. https://qiita.com/Takepepe/items/f1ba99a7ca7e66290f24 ↩︎

ここのえここのえ

Props (関数渡し・引数有り)

クリックするとコンソールに2の二乗を表示するプログラムを実装。
onClickにはあくまで関数を渡す必要があります。
なのでonClickにはアロー関数などを渡す必要があるのですが、中に書いてしまうと再描画の度に関数を生成するのであまり適切ではない[1]ようです。HooksではuseCallbackを使うことが推奨のようですが、propsの説明なのでここでは一旦省きます。

Sample.tsx
import { FC } from "react";

type Props = {
  value: number;
  onClickButton: (num: number) => void;
};

export const Sample: FC<Props> = ({ value, onClickButton }) => {
  return (
    <>
      <button onClick={() => onClickButton(value)}>Click Event</button>
    </>
  );
};
App.tsx
import { Sample } from "./Components/Sample.tsx";

function App() {
  const squared = (num: number) => {
    console.log(num * num);
  };

  return (
    <>
      <Sample value={2} onClickButton={squared} />
    </>
  );
}

Vueの場合、v-on が吸収してくれるのでそんなことを気にしなくてもただ関数と引数を書くだけです。

Sample.vue
<script setup lang="ts">
const props = defineProps<{
  value: number
  onClickButton: (num: number) => void
}>();
</script>

<template>
  <button v-on:click="props.onClickButton(props.value)">Push!</button>
</template>
App.vue
<script setup lang="ts">
import Sample from "./components/Sample.vue";
const squared = (num: number) => {
  console.log(num*num);
}
</script>

<template>
  <Sample :value="2" :on-click-button="squared" />
</template>
脚注
  1. https://qiita.com/tsuuuuu_san/items/73747c8b6e6e28f6bd23 ↩︎

ここのえここのえ

Slot (概念が存在しない)

Reactでは、Vueのslotのような概念はない。渡されたものはpropのchildrenとして飛んでくる。
そのため名前付きslotのような機能はない(近しいことはできなくもない[1])。

Sample.tsx
import { FC, ReactNode } from "react";

type Props = {
  children: ReactNode;
};

export const Sample: FC<Props> = (props) => {
  return (
    <>
      <button>{props.children}</button>
    </>
  );
};
App.tsx
import { Sample } from "./Components/Sample.tsx";

function App() {
  return (
    <>
      <Sample>
        <strong>aiueo</strong>
      </Sample>
    </>
  );
}

Vueはいたってシンプル。

Sample.vue
<template>
  <button>
    <slot/>
  </button>
</template>
App.vue
<script setup lang="ts">
import Sample from "./components/Sample.vue";
</script>

<template>
  <Sample>
    <strong>aiueo</strong>
  </Sample>
</template>
脚注
  1. https://zenn.dev/wintyo/articles/f2c4f6a11cf418 ↩︎

ここのえここのえ

if

React: HTML内でのif

論理演算子&&や三項演算子?が使える。
if文は使えない

Sample.tsx
export const Sample: FC = () => {
  const flag = true;

  return (
    <>
      {flag && <p>foo</p>}
      {flag ? <p>bar</p> : <p>baz</p>}
    </>
  );
};

React: returnの外でのif

当然と言えば当然だが、ただのJSの関数なので使える。

Sample.tsx
import { FC } from "react";

export const Sample: FC = () => {
  const flag = true;

  if (flag) {
    return <p>foo</p>
  } else {
    return <p>bar</p>
  }
};

Vueの場合 v-if

もはやサンプルを書く必要すらないかも

Sample.vue
<script setup lang="ts">
const flag = true
</script>

<template>
  <p v-if="flag">foo</p>
  <p v-else>bar</p>
</template>
ここのえここのえ

for

React

こっちもHTMLの中にfor文は書けないし、return文をforで回すわけにもいかない。
なので Array.mapなどを使って回す。

Vueのv-forと同様に、keyを渡すことが推奨されている。

Sample.tsx
export const Sample: FC = () => {
  const users = ["Taro", "Jiro", "Foo", "Bar", "John"];

  return (
    <>
      <ul>
        {users.map((name) => (
          <li key={name}>{name}</li>
        ))}
      </ul>
    </>
  );
};

Vue

v-for を使う。こっちも説明の必要はないが念の為。

Sample.vue
<script setup lang="ts">
const users = ["Taro", "Jiro", "Foo", "Bar", "John"];
</script>

<template>
  <ul>
    <li v-for="user of users">{{user}}</li>
  </ul>
</template>
ここのえここのえ

Hook

ここから本題。たぶんVueと比較するのが難しそうな気がしている。

Hookとは

https://ja.legacy.reactjs.org/docs/hooks-overview.html

React 16.8から追加された。
クラスを使わなくても、Reactのstateなどの機能が使えるという機能。

Vueで言うところのOptions API - Composition APIの関係性に近く、昔のクラスベースのReactと新しいHookで共存させることができる。Options APIと同様に、過去のクラスベースの構文が今後消えるわけでもないらしい。

ここのえここのえ

useState

state はコンポーネントが保持するメモリの役割を果たす[1]
Vueで言うところの ref() に近いかも。

以下のようにして使う。

Sample.tsx
import { FC, useState } from "react";

export const Sample: FC = () => {
  const [name, setName] = useState("Taro");

  return (
    <>
      <p>Hello, {name} !</p>
    </>
  );
};

例えば上記のように statestring だった時は、useState() はこんな感じ。

export function useState<string>(initialState: string | (() => string)): [string, React.Dispatch<React.SetStateAction<string>>

Arrayが返ってくるので分割代入する。
第一に値、第二に値を変更するための Dispatch が返ってくる。
変数名は慣例的に [<stateName>, set<StateName>] とする。

const [name, setName] = useState("Taro");

コンポーネントの state に変更があった場合、もしくは親コンポーネントが再レンダリングされた場合、Reactは コンポーネント全体を再レンダリングします


stateは snapshot である

state 変数は特殊な取り扱いで、スナップショットのような振る舞いになっている。
この公式ドキュメントを読めばいいけど、ここでは一応改めて要約する。

https://ja.react.dev/learn/state-as-a-snapshot

以下の説明では、このstateを例として取り上げます。

const [name, setName] = useState("Taro");

(1) stateは readonly で、値を変更する時は setState() を使う

stateの値である nameimmutable(不変) であり、この値を直接変更することはできない。

// ❌これはやってはだめ。
name = "foo bar"

値を変更する時は必ず setter を使う。

setName("foo bar")

stateで配列を取り扱うときも Array.push() してはいけない。

// ❌直接変更してはいけない、何故ならimmutableなので
const [arr, setArr] = useState(["a", "b", "c"]);
arr.push("d");

そのため別で配列を作って、それをsetする。

const [arr, setArr] = useState(["a", "b", "c"]);
setArr([...arr, "d"])

stateのImmutability(不変性)の重要性については、公式ドキュメントを参照。

https://ja.react.dev/learn/tutorial-tic-tac-toe#why-immutability-is-important

上記ドキュメントにあるように、この特性によってstateの状態を容易に把握できるようになるため、パフォーマンスの効率の向上に繋がる。stateの巻き戻しが必要になる場面はあまり思いつかないが…

(2) 新しい値を set しても、レンダリングするまでは変更されない

以下のようなコードがあったとします。
ボタンを押された後、name は何になるでしょうか?

Sample.tsx
export const Sample: FC = () => {
  const [name, setName] = useState("a");

  const changeName = () => {
    setName(name + name);
    setName(name + name);
  };

  return (
    <>
      <p>Hello, {name} !</p>
      <button onClick={changeName}>Change Name</button>
    </>
  );
};

setName(name + name) を2回走らせていますが、出力されるのは aaです

直感に反しますが、これが state が snapshot である所以です。

setName は実際には以下の手順で処理を行います。

  1. setName(value) によって、valuenameを再レンダリングすることを予約する
  2. レンダリングのタイミングで、valueによってnameを更新し、再レンダリング&DOMの変更を行う

この手順で処理されるので、要はレンダリングされるまで nameは更新されません。なので今回の例では aa しか表示されませんでした。

先程のコードをaaaaで出力したければ、別で変数を用意しておき最後にsetします。

  const changeName = () => {
    let result = name;
    result += result;
    result += result;
    setName(result);
  };

Vueのような、Static Hoistingはない

Vueとの大きな違いとして、ReactにはStatic Hoisting[2] (Hostingじゃないよ!) がないようで、以下のようなコードを書くとReactではボタンを押すたびに大遅延が発生してしまう…

Reactの再レンダリングタイミングは、

  • コンポーネントの state が更新されたとき
  • 親コンポーネントが再レンダリングされたとき

が条件のようなので、レンダリングコストを考慮しながらコンポーネントを切り分けていく必要がありそう。

Sample.tsx
// ボタンを押すたび、1000msの遅延が発生する

import { FC, useState } from "react";
import { SuperHeavy } from "./SuperHeavy.tsx";

export const Sample: FC = () => {
  const [name, setName] = useState("Taro");

  const changeName = () => {
    setName(name + name);
  };

  return (
    <>
      <p>Hello, {name} !</p>
      <button onClick={changeName}>Change Name</button>
      <SuperHeavy />
    </>
  );
};
SuperHeavy.tsx
import { FC } from "react";

export const SuperHeavy: FC = () => {
  const now = performance.now();
  while (performance.now() - now < 1000) {}

  return (
    <>
      <p>wow</p>
    </>
  );
};

VueはStatic Hoistingで静的なv-nodeは別で保持して再利用されるので、値の変更が発生する箇所だけ処理される。そのため以下の例でも、ページを開いたときしか1000msの待ち時間は発生せず、ボタンを何度押しても即時反映される。

Sample.vue
<script setup lang="ts">
// 1000ms待たされるのはページロード時だけ。
import {ref} from "vue";
import SuperHeavy from "./SuperHeavy.vue";

const name = ref("Taro")
const changeName = () => {
  name.value += name.value
};
</script>

<template>
  <p>Hello, {{name}} !</p>
  <button @click="changeName">Change Name</button>
  <SuperHeavy />
</template>
SuperHeavy.vue
<script setup lang="ts">
const now = performance.now();
while (performance.now() - now < 1000) {}
</script>

<template>
  <p>wow</p>
</template>

そういった理由から、Reactでは再レンダリングが不必要に行われないように注意して設計する必要がありそう。
もしくはメモ化を使う(メモ化周りは後程書く…)。
この記事がとても分かりやすかったです。

https://zenn.dev/azukiazusa/articles/react-rerender-patterns

脚注
  1. https://ja.react.dev/learn/state-a-components-memory ↩︎

  2. https://ja.vuejs.org/guide/extras/rendering-mechanism#static-hoisting ↩︎

ここのえここのえ

useReducer

useStateと同様に state を更新するものだが、間に自分で定義した関数を挟むことができる。
VuexやPiniaのような状態管理ライブラリの考え方に近いかもしれない(ただし、グローバルではない)。
useReducer の使い方は以下の通り。

const [state, dispatch] = useReducer(reducer, initialState);

先にサンプルを見た方が分かりやすいかも。
例えば、UIのテーマやレスポンシブの状態といった、ブラウザの状態を保持する state を考える。

(1) state 本体を定義する

まずState本体そのものを定義する。
今回は width, height, breakpoint, theme の4つを持っている。

export type Browser = {
  width: number;
  height: number;
  breakpoint: "sm" | "md" | "lg";
  theme: "light" | "dark";
};

(2) デフォルト値を定義

useReducer では初期状態を渡す必要があるので、作っておく。

export const browserDefault: Browser = {
  width: 0,
  height: 0,
  breakpoint: "sm",
  theme: "light",
};

(3) アクションを定義

reducerがどんなアクションを持っているか定義する。
今回はtypeargs を定義して、それによってアクションのタイプや与える引数を定義した。
breakpointwidth に従って変更されるので、引数として与える必要はない。

export type BrowserAction =
  | {
      type: "RESIZED";
      args: {
        width: number;
        height: number;
      };
    }
  | {
      type: "CHANGE_THEME";
      args: {
        theme: "light" | "dark";
      };
    };

(4) reducer を定義

実際にreducerを定義する。
action.type にタイプ、action.args に引数が来るので、それに応じてstateを更新。
Reducerとして定義した関数は、return で新しいstateを返す必要があります。

export const browserReducer = (state: Browser, action: BrowserAction): Browser => {
  switch (action.type) {
    case "RESIZED":
      let newbp: "sm" | "md" | "lg";
      if (action.args.width < 640) {
        newbp = "sm";
      } else if (action.args.width < 768) {
        newbp = "md";
      } else {
        newbp = "lg";
      }
      return {
        ...state,
        width: action.args.width,
        height: action.args.height,
        breakpoint: newbp,
      };

    case "CHANGE_THEME":
      return {
        ...state,
        theme: action.args.theme,
      };
  }
};

(5) 使う

後はコンポーネント側で利用します。
reducer周りはただのtypefunctionなので、別ファイルに記述することができるのも一つのメリット。コンポーネントの定義とごちゃごちゃにならず可読性が上がります。

Sample.tsx
import { FC, useReducer } from "react";
import { browserDefault, browserReducer } from "./BrowserReduser.tsx";

export const Sample: FC = () => {
  const [browser, browserDispatch] = useReducer(browserReducer, browserDefault);

  const handleResize = () => {
    const width = window.innerWidth;
    const height = window.innerHeight;
    browserDispatch({ type: "RESIZED", args: { width, height } });
  };

  const handleTheme = (theme: "light" | "dark") => {
    browserDispatch({ type: "CHANGE_THEME", args: { theme } });
  };

  return (
    <>
      <div>
        <h1>Current State</h1>
        <ul>
          <li>
            Size: {browser.width} x {browser.height}
          </li>
          <li>Breakpoint: {browser.breakpoint}</li>
          <li>theme: {browser.theme}</li>
        </ul>
      </div>

      <div>
        <h1>Change Buttons</h1>
        <button onClick={handleResize}>Resized</button>
        <button
          onClick={() => {
            handleTheme("light");
          }}
        >
          Change light theme
        </button>
        <button
          onClick={() => {
            handleTheme("dark");
          }}
        >
          Change dark theme
        </button>
      </div>
    </>
  );
};

この記事を参考にしました。

https://zenn.dev/loxygenk/articles/react-usereducer


useStateとの使い分け

以下のような状態なら、useState よりも useReducer を使う動機がありそう。

  • stateをある程度のデータのまとまり・オブジェクトとして扱いたい時に…
    • データの集合体として使いたいが、一部データのみの更新パターンがある (themeだけ変えたいなど)
    • state内のフィールドの情報を基に、別フィールドの値を動的に生成したい (上記breakpointの例)
  • 上限・下限値など、setterの処理を自分で制御したい
  • stateの更新処理が複雑で、コンポーネント外に切り出して可読性を上げたい
  • 複雑なデータなどを取り扱うstateで、テストのためにコンポーネントから分離したい
脚注
  1. https://ja.react.dev/learn/extracting-state-logic-into-a-reducer ↩︎

ここのえここのえ

useContext

ユーザのログイン情報や、カラーテーマなど、上のコンポーネントから下のコンポーネント、特に深い階層まで渡そうとしたとき、props で全部渡すと地獄みたいなことになってしまう…。
特に中間コンポーネントでは値を使用しないのに、最深部のコンポーネントだけで使うような場合は最悪なことになってしまう。

useContext を使う事でこれを解消することができ、Context以下のコンポーネント全体にデータを送ることができる。

今回は親コンポーネント→孫コンポーネントにカラーテーマを送ってボタンの色を変更する例を考える。

(1) Contextを作成

まずコンポーネント でContextを作成する。

export type ColorTheme = "light" | "dark";
export const ColorThemeContext = createContext<ColorTheme | null>(null);

(2) Contextを Provide する

親コンポーネントのreturnするHTML内に、コンポーネントのようにタグを追加します。
Provider 以下全てのコンポーネントに値が伝播します。

export const Sample: FC = () => {
  return (
    <>
      <ColorThemeContext.Provider value={"dark"}>
        <SampleChild />
      </ColorThemeContext.Provider>
    </>
  );
};

中間コンポーネントも作っておきます。

export const SampleChild: FC = () => {
  return (
    <>
      <SampleGrandChild />
    </>
  );
};

(3) useContextで値を取得する

孫コンポーネント内で useContext して、contextの値を受け取ります。
受け取った値は普通に使えます。

export const SampleGrandChild: FC = () => {
  const theme = useContext(ColorThemeContext);

  const lightStyle = {
    color: "gray",
    backgroundColor: "white",
  };

  const darkStyle = {
    color: "white",
    backgroundColor: "gray",
  };

  if (theme === "light") {
    return <button style={lightStyle}>Light Theme</button>;
  } else {
    return <button style={darkStyle}>Dark Theme</button>;
  }
};
📌コード全体
Sample.tsx
export type ColorTheme = "light" | "dark";
export const ColorThemeContext = createContext<ColorTheme | null>(null);

export const Sample: FC = () => {
  return (
    <>
      <ColorThemeContext.Provider value={"dark"}>
        <SampleChild />
      </ColorThemeContext.Provider>
    </>
  );
};

export const SampleChild: FC = () => {
  return (
    <>
      <SampleGrandChild />
    </>
  );
};

export const SampleGrandChild: FC = () => {
  const theme = useContext(ColorThemeContext);

  const lightStyle = {
    color: "gray",
    backgroundColor: "white",
  };

  const darkStyle = {
    color: "white",
    backgroundColor: "gray",
  };

  if (theme === "light") {
    return <button style={lightStyle}>Light Theme</button>;
  } else {
    return <button style={darkStyle}>Dark Theme</button>;
  }
};

複数の Provider が存在する時

useContext に対して上の階層のコンポーネントを見に行った時、複数のProviderがあった時は、呼び出すコンポーネントに最も近い Provider の値を参照する。

下記の例では、SampleGrandChildthemelight になる。

export const Sample: FC = () => {
  return (
    <>
      <ColorThemeContext.Provider value={"dark"}>
        <SampleChild />
      </ColorThemeContext.Provider>
    </>
  );
};

export const SampleChild: FC = () => {
  return (
    <>
      <ColorThemeContext.Provider value={"light"}>
        <SampleGrandChild />
      </ColorThemeContext.Provider>
    </>
  );
};

export const SampleGrandChild: FC = () => {
  const theme = useContext(ColorThemeContext);
  // --省略--

  if (theme === "light") {
    return <button style={lightStyle}>Light Theme</button>;
  } else {
    return <button style={darkStyle}>Dark Theme</button>;
  }
};

Stateとの組み合わせ

stateと組み合わせることで、値が変更されたら再レンダリングを走らせることができる。
ボタンを押したら孫コンポーネントの色が変わる例。

export const Sample: FC = () => {
  const [theme, setTheme] = useState<ColorTheme>("light");
  const changeTheme = () => {
    setTheme(theme === "light" ? "dark" : "light");
  };

  return (
    <>
      <ColorThemeContext.Provider value={theme}>
        <SampleChild />
      </ColorThemeContext.Provider>
      <button onClick={changeTheme}>Change Theme</button>
    </>
  );
};

export const SampleChild: FC = () => {
  return (
    <>
      <SampleGrandChild />
    </>
  );
};

export const SampleGrandChild: FC = () => {
  const theme = useContext(ColorThemeContext);

  const lightStyle = {
    color: "gray",
    backgroundColor: "white",
  };

  const darkStyle = {
    color: "white",
    backgroundColor: "gray",
  };

  if (theme === "light") {
    return <button style={lightStyle}>Light Theme</button>;
  } else {
    return <button style={darkStyle}>Dark Theme</button>;
  }
};
ここのえここのえ

useRef

コンポーネントに情報を保存したいが、レンダリングはしたくない時に使う。
Vueの ref と名前は似ているが…別物と言える?

const ref = useRef(0)

state との大きな違いは以下の通り。

  • ref を更新しても、レンダリングは走らない。
  • mutableであり、Dispatch を介さずに直接値を変更することができる。

クリックすると alert() でnumを表示するサンプル。

Sample.tsx
import { FC, useRef, useState } from "react";

export const Sample: FC = () => {
  const num = useRef(0);

  const handleClick = () => {
    num.current++;
    alert(`num: ${num.current}`);
  };

  return (
    <>
      <button onClick={handleClick}>Click!</button>
    </>
  );
};

Vueの場合。レンダリングに関係ない変数は普通に let で定義すればよい。useRefのような特殊なことをしなくてもいい。

Sample.vue
<script setup lang="ts">
let num = 0
const handleClick = () => {
  num++
  alert(`num: ${num}`)
};
</script>

<template>
  <button @click="handleClick">Click!</button>
</template>

Reactの場合、useRefを使わないとどうなる?

レンダリングに関係ないなら普通に変数定義すればいいのでは?とも考えるが、Reactの場合通常の変数はレンダリング時にコードが丸ごと走りなおすので、変数の値が初期値に戻ってしまう。

例えば以下のようなサンプルで、numuseRef を使い、 nn は普通の変数定義にする。

Sample.tsx
import { FC, useRef, useState } from "react";

export const Sample: FC = () => {
  const num = useRef(0);
  let nn = 0;

  const [testState, setTestState] = useState(0);
  const handleClick = () => {
    num.current++;
    nn++;
    alert(`num: ${num.current}\nnn: ${nn}`);
  };

  const handleStateClick = () => {
    setTestState(testState + 1);
  };

  return (
    <>
      <button onClick={handleClick}>Click!</button>
      <button onClick={handleStateClick}>Update State: {testState}</button>
    </>
  );
};

結果はこうなってしまう。

[handleClick]    ->  num: 1    nn:1
[handleClick]    ->  num: 2    nn:2
[handleClick]    ->  num: 3    nn:3
[handleStateClick]    ->   num:3   nn:0
[handleClick]    ->  num: 4    nn:1

stateの更新などがかかると、レンダリングが走って通常の変数は再度初期化されてしまうため、値が初期値に戻ってしまう。そのため内部的に値を保持するには useRef が必須。

実際のユースケース: DOM要素の参照に使う

実際のユースケースとしては、DOM要素の参照を拾うのによく使われている様子。

例えば width: 70% のボックスの width を取得するサンプル…

Sample.tsx
import { FC, useRef } from "react";

export const Sample: FC = () => {
  const boxRef = useRef<HTMLDivElement>(null);
  const handleClick = () => {
    alert(`70% width: ${boxRef.current?.clientWidth}`);
  };

  return (
    <>
      <div
        style={{
          margin: "0 auto",
          width: "70%",
          color: "white",
          backgroundColor: "black",
        }}
        ref={boxRef}
      >
        test box
      </div>
      <button onClick={handleClick}>Calc size</button>
    </>
  );
};

結果はこんな感じ。