White Box技術部

WEB開発のあれこれ(と何か)

【React】Quillを表示するカスタムフックと、それをReact Hook Formで使うサンプル

QuillというWYSIWYGエディタをReact Hook Formで使うメモです。

主要ライブラリ

動作時のライブラリバージョンは以下になります。

  • dependencies
    • "react": "18.2.0"
    • "react-hook-form": "7.44.3"
    • "quill": "1.3.7"
    • "react-quilljs": "1.3.3"
  • devDependencies
    • "@types/node": "20.4.10"
    • "@types/quill": "2.0.10"
    • "@types/react": "18.2.20"
    • "@types/react-dom": "18.2.7"

カスタムフック作成

Quillを使い回せるようにカスタムフックにしました。

hooks/useQuill.ts

import 'quill/dist/quill.snow.css';
import { useEffect, useState } from 'react';
import { useQuill } from 'react-quilljs';

export const useReactQuill = (defaultValue?: string) => {
  const { quill, quillRef } = useQuill();
  const [value, setValue] = useState(defaultValue ?? '');

  const imageHandler = () => {
    const input = document.createElement('input');
    input.setAttribute('type', 'file');
    input.setAttribute('accept', 'image/*');
    input.click();

    input.onchange = () => {
      if (!input) {
        return;
      }
      const file: File = (input.files as FileList)[0];

      if (/^image\//.test(file.type)) {
        saveImage(file);
      } else {
        alert('画像のみアップロードできます。');
      }
    };
  };

  const saveImage = async (file: File) => {
    const imageUrl = await uploadImage(file); // TODO: 好みの画像アップロード処理を書いて、画像へのアクセスURLを返してあげる
    if (quill) {
      const range = quill.getSelection();
      if (range) {
        quill.insertEmbed(range.index, 'image', imageUrl);
      }
    }
  };

  useEffect(() => {
    if (quill && value) {
      quill.clipboard.dangerouslyPasteHTML(value);
    }
    if (quill) {
      quill.getModule('toolbar').addHandler('image', imageHandler);
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      quill.on('text-change', (delta, oldDelta, source) => {
        setValue(quill.root.innerHTML);
      });
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [quill]);

  return { quillRef, value, setValue } as const;
};

uploadImageは別途実装が必要です。 画像を埋め込まないのであれば、imageHandler関連部分をまるっと削除しても大丈夫です。

Quill表示の極小サンプル

React Hook Formで上記のカスタムフックを利用する極小サンプルです。

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useReactQuill } from '@/hooks/useQuill';

type Form = {
  description: string;
};
type Props = {
  description?: string;
};

export const Sample = ({ description }: Props) => {
  const { quillRef, value } = useReactQuill(description);
  const {
    register,
    setValue,
  } = useForm<Form>({ mode: 'onBlur' });

  useEffect(() => {
    // register('description', { required: true, minLength: 15 });
    register('description');
  }, [register]);

  useEffect(() => {
    setValue('description', value);
  }, [value]);

  return (<div ref={quillRef} />);
};

サンプルでは入力チェックを入れてませんが、コメントアウトしている箇所のようにして、入力チェックの設定を追加することもできます。

ReactとSassそれぞれのSP判定について

レスポンシブ対応したサイトを作成する場合、PCとSPでスタイルを切り替えたり、表示内容を変えたりすると思うのですが、 私がやっているTSX側とSCSS側の切り替え方法について記載します。

React側の準備

react-responsiveを使って、指定サイズ以下になった場合をSPとして判定します(ここでは860px)。

インストール

$ yarn add -E react-responsive
-> "react-responsive": "9.0.2

判定コード

hooks/useSp.ts

import { useMediaQuery } from 'react-responsive';

export const useSp = () => {
  return useMediaQuery({ maxWidth: 860 });
};

Sass側の準備

CSSの切り替えはSassを使うと共通化できて便利です。

インストール

$ yarn add -E sass
-> "sass": "1.64.1"

判定コード

CSSのメディアクエリーを使って、指定サイズ以下で判定されるSassのミックスインを定義します。

styles/utils.scss

@mixin sp {
  @media screen and (max-width: 860px) {
    @content;
  }
}

これでユーティリティの準備は終わりです。

利用例

上記で作成した判定コードは、以下のようにして利用することができます。

SCSS側

SampleComponent.module.scss

@use 'styles/utils';

.container {
  width: 400px;

  @include utils.sp {
    width: 100%;
  }
}

デフォルトのスタイルはPC用で定義し、SP用に変更が必要な要素だけ上書くように書きます。

TSX

SampleComponent.tsx

import { useSp } from '@/hooks/useSp';
import ss from './SampleComponent.module.scss';

export const SampleComponent = () => {
  const isSp = useSp();
  return <div className={ss.container}>{isSp ? 'SP表示' : 'PC表示'}</div>;
}

importパスの@/が使えるように、tsconfig.jsonに以下の設定を追加しています。 "baseUrl": "src", "paths": { "@/*": ["./*"] },

なんかうまくいかない場合

そもそもレスポンシブを有効にするには、htmlのheadタグにviewportのmetaタグを設定する必要があるので、 以下のような設定が入っているかを確認してください。

<head>
  <meta name='viewport' content='width=device-width, initial-scale=1' />
</head>

またuseSpはクライアントサイドでの動作になるので、レンダリング前は判定が動きません。
これらに気をつけて実装を行ってください。

【React】未編集時に確認ダイアログを出すカスタムフック

『Formデータに編集済みデータがあれば、ブラウザの戻るが実行された場合に確認ダイアログを出す』というよくあるやつを出したくなって調べたところ、 この記事にあたりました。

わかりやすくていい記事だったので、やりたい人はこれを見てくださいで良いのですが、私はカスタムフックにして利用しました。

ちなみにnext/navigationの方のRouterだと、beforePopStateが存在しないので、appDirを使っている場合はEventListenerを使いましょう。

カスタムフック

useBackConfirm.ts

import { useState, useEffect } from "react";

const CONFIRM_MESSAGE = "保存されていないデータは削除されますが、よろしいですか?";

// setIsEditedにtrueが設定されていた場合、ページバック時に確認モーダルを表示する
export const useBackConfirm = (message?: string) => {
  const [isEdited, setIsEdited] = useState(false);

  const handlePopstate = () => {
    const ok = confirm(message || CONFIRM_MESSAGE);
    if (ok) {
      setIsEdited(false);
      window.history.back();
    } else {
      history.pushState(null, "", null);
    }
  };

  useEffect(() => {
    if (isEdited) {
      // ダミー履歴を挿入して「戻る」を1回分吸収する
      history.pushState(null, "", null);
      window.addEventListener("popstate", handlePopstate, false);
    }
    return () => {
      window.removeEventListener("popstate", handlePopstate, false);
    };
  }, [isEdited]);

  return { setIsEdited } as const;
};

使い方

react-hook-formで利用する場合は、formState.isDirtyの値をuseEffectで見て、setIsEditedに値を設定すると楽です。

  const { setIsEdited } = useBackConfirm();
  const { register, formState: { isDirty } } = useForm<SampleForm>();

  useEffect(() => {
    if (isDirty) {
      setIsEdited(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isDirty]);

このサンプルコードではダイアログのメッセージを変えたい場合にも対応しましたが、 この手の確認メッセージを一部だけ変えたいというのは実運用だと無いと思うので、パラメータは無い方が利用時に迷わなくて良いかもしれません。

【TypeScript】nodeのfetchとaxiosのタイムアウト処理

戒めを込めて。

最初からaxiosを使おう(結論)

HTTPクライアントとしてFetch APIが標準化されてから、「標準とか言うならfetch使うべき?」とか思ってリクエストをfetchで実装し、 しばらくしてタイムアウト処理が欲しくなった段階で、「これなら最初からaxios使っておけばよかった」となる理由をコード比較で見ていきたいと思います。

fetchでのタイムアウト処理の書き方

fetchはnode18くらいから、外部ライブラリなしで利用することができます。 ですが、リクエスタイムアウトをオプション等で実現することはできないため、以下のようなコードを書く必要があります。

タイムアウト設定

const fetchWithTimeout = async (
  url: string,
  method: string,
  body?: string,
  timeout = 10000
): Promise<Response> => {
  const controller = new AbortController();
  const timer = setTimeout(() => {
    console.error(`${method} ${url} timeout!`);
    controller.abort();
  }, timeout);

  const options: RequestInit = {
    method,
    body,
    headers: body
      ? {
          Accept: "application/json",
          "Content-Type": "application/json",
          "Content-Length": `${body.length}`, // ここまでしなくても動きはする
        }
      : {
          Accept: "application/json",
        },
    signal: controller.signal,
  };
  const response = await fetch(url, options);
  clearTimeout(timer);

  return response;
};

コード量に「え・・・」ってなりますね・・・

使い方

fetchWithTimeoutは、fetchをほぼラップしただけなので、使う方は難しくはありません。
async/awaitで書くとこんな感じです。

type SampleResponse = { result: string };

export const sampleProcess = async (url: string): Promise<void> => {
  // GET
  const response = await fetchWithTimeout(url, "GET");
  if (response.ok) {
    const data: SampleResponse = await response.json();;
    console.log(data);
  } else {
    // エラー処理
    const data = await response.json(); // もし500エラー等でJSONを戻してなければ、この書き方はエラーになる
    console.error(data);
  }

  // POST
  const body = JSON.stringify({ id: "sample_id", name: "sample" });
  const response = await fetchWithTimeout(url, "POST", body);
  if (response.ok) {
    const data: SampleResponse = await response.json();
    console.log(data);
  } else {
    // エラー処理
  }
};

axiosでのタイムアウト処理の書き方

一方、axiosを使ってタイムアウト付きリクエストを行う手順です。

インストール

axiosは型定義もライブラリに入っているので、TypeScriptで使う場合もaxiosだけを追加すればOKです。

$ npm i -E axios
or
$ yarn add -E axios

最近、Eオプションを付けてインストールするようになりました。こうするとバージョン番号に^が付かなくなります。

タイムアウト設定と使い方

import axios from "axios";

// 設定部分
const client = axios.create({
  timeout: 10000,
  headers: {
    Accept: "application/json",
  },
});

// 使用例
type SampleResponse = { result: string };

export const sampleProcess = async (url: string): Promise<void> => {
  try {
    // GET
    const result = await client.get<SampleResponse>(url);
    console.log(result.data);

    // POST
    const body = JSON.stringify({ id: "sample_id", name: "sample" });
    const result = await client.post<SampleResponse>(url, body);
    console.log(result.data);

  } catch (error) {
    console.error(error);
  }
};

axiosだとfetchWithTimeoutの定義部分と同じくらいのコード量で使用例まで書けますね。

axios自体をもっと知りたい場合は公式を参照してください。

ということで、「こんなことなら最初からaxios入れておけばよかった・・・」とタイムアウト処理を書くときに思った理由のコード比較でした。