🤓

Reactコンポーネント単体のファイル分割について

2022/05/30に公開

最近、プロジェクトで個人的に採用しているファイルの分け方について共有します。
これは、AtomicDesignの様な全体構成の話ではなく、Reactコンポーネント単体を見た時に、ディレクトリ内でどの様にファイルを分けているかについての話になります。

前提として

  • 1画面が少数フォームとボタンで構成されるシンプルなもの(下記のようなレベル)

  • 対象とするReactコンポーネントは、AtomicDesignでいうOrganismsレベルのもの

  • 採用している技術スタックは、CSSはemotion, グローバルデータ管理はrecoil, フォーム制御にreact-hook-formとyup

紹介したいファイル構成は以下です。

Component/
|- index.tsx
|- logic.ts
|- localConstant.ts
|- errorScheme.ts

以下にそれぞれのファイルの役割についての説明を記載しています。

index.tsx

  • 各種import、jsx、cssの記述のみに集中
  • メインロジックは、logic.tsに退避し、index.tsxにはロジックをゴリゴリ書かない方針
  • 1画面がシンプルなことやデザインシステムや別ディレクトリに配置しているコンポーネントを使用している関係上、ほぼコンポーネントを置くだけの開発体験が実現中
import { css } from '@emotion/core';
import { Input } from '@lancers/design_guideline'; // Inputフォーム
import { FormLayout } from 'component/layout'; // 送信ボタンを含むレイアウトコンポーネント
import { TitleDescriptionHorizontal } from 'component/ui'; // タイトル、詳細を表示するコンポーネント
import { mt8Css, mt40Css } from 'helper';
import React from 'react';
import { useLogic } from './logic';

export const UserPassword: React.FC = () => {
  const {
    handleSubmit,
    onSubmit,
    nicknameRegister,
    passwordRegister,
    purposeRegister,
    errors,
  } = useLogic();

  return (
    <FormLayout
      title="ユーザー名とパスワードを設定してください"
      buttonType="single"
      handleSubmit={handleSubmit}
      onSubmit={onSubmit}
    >
      <TitleDescriptionHorizontal
        title="ユーザー名"
        description="半角英数字記号 4文字以上(使用可能記号 -_ )"
      />
      <Input
        placeholder="例:user_001"
        css={mt8Css}
        name={nicknameRegister.name}
        inputRef={nicknameRegister.ref}
        onChange={nicknameRegister.onChange}
        onBlur={nicknameRegister.onBlur}
        errorMessage={errors.nickname?.message}
      />
      <TitleDescriptionHorizontal
        title="パスワード"
        description="半角英数字 記号 8文字以上"
        css={mt40Css}
      />
      <Input
        placeholder="例:user_001"
        type="password"
        css={mt8Css}
        name={passwordRegister.name}
        inputRef={passwordRegister.ref}
        onChange={passwordRegister.onChange}
        onBlur={passwordRegister.onBlur}
        errorMessage={errors.password?.message}
      />
      <TitleDescriptionHorizontal
        title="主な利用方法"
        description="どちらを選んでも、両方ご利用いただけます"
        css={mt40Css}
      />
      <RadioButtons
        css={mt8Css}
        name="purpose"
        inputRef={purposeRegister.ref}
        onChange={purposeRegister.onChange}
        values={[
          { value: '0', text: '仕事を発注したい' },
          { value: '1', text: 'フリーランス・副業・在宅で働きたい' },
        ]}
        errorMessage={errors.purpose?.message}
      />
    </FormLayout>
  );
};

logic.ts

  • メインのロジックを記述するファイル
  • index.tsxで利用する為に必要な変数や関数をreturnするカスタムフック的な記述
  • 1画面がシンプルなので、記述コードは、ほとんどフォーム制御やAPIとの接続部分のみ
import { yupResolver } from '@hookform/resolvers/yup';
import { postFirstStepSave } from 'api';
import { FORM_NAME } from 'constant';
import {
  FieldError,
  FieldValues,
  useForm,
  UseFormHandleSubmit,
  UseFormRegisterReturn,
} from 'react-hook-form';

import { errorScheme } from './errorScheme';
import { LOCAL_CONSTANT } from './localConstant';

type SubmitDataArgumentType = {
  [LOCAL_CONSTANT.NICKNAME_KEY]: string;
  [LOCAL_CONSTANT.PASSWORD_KEY]: string;
  [LOCAL_CONSTANT.PURPOSE_KEY]: '0' | '1' | '';
};

type ReturnType = {
  handleSubmit: UseFormHandleSubmit<FieldValues>;
  onSubmit: (data: SubmitDataArgumentType) => Promise<void>;
  nicknameRegister: UseFormRegisterReturn;
  passwordRegister: UseFormRegisterReturn;
  purposeRegister: UseFormRegisterReturn;
  errors: {
    nickname?: FieldError;
    password?: FieldError;
    purpose?: FieldError;
  };
};

export const useLogic = (): ReturnType => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SubmitDataArgumentType>({
    mode: 'onSubmit',
    criteriaMode: 'all',
    shouldFocusError: false,
    resolver: yupResolver(errorScheme),
  });
  const nicknameRegister = register(LOCAL_CONSTANT.NICKNAME_KEY);
  const passwordRegister = register(LOCAL_CONSTANT.PASSWORD_KEY);
  const purposeRegister = register(LOCAL_CONSTANT.PURPOSE_KEY);

  const onSubmit = async (data: SubmitDataArgumentType) => {
    // API処理
  };

  return {
    handleSubmit,
    onSubmit,
    nicknameRegister,
    passwordRegister,
    purposeRegister,
    errors,
  };
};

localConstant.ts

  • コンポーネント内で利用する定数を管理
  • グローバルな単位で利用する定数は、別途グローバル用の定数ファイルで管理
export const LOCAL_CONSTANT = {
  NICKNAME_KEY: 'nickname',
  PASSWORD_KEY: 'password',
  PURPOSE_KEY: 'purpose',
  EMAIL_HASH_KEY: 'email_hash',
  NICKNAME_MIN_LENGTH: 4,
  NICKNAME_MAX_LENGTH: 25,
  PASSWORD_MIN_LENGTH: 8,
  PASSWORD_MAX_LENGTH: 32,
} as const;

errorScheme.ts

  • yupで記述したバリデーションを退避したファイル
  • react-hook-formを利用している為、バリデーションの記述もその範囲で出来ますが、logic.tsがごちゃごちゃしてしまう記述になる為、yupを採用
  • このファイルのおかげで、logic.tsがよりシンプルに

react-hook-formでバリデーションを記述する場合
各registerにルールを直接記述しないといけないので可読性が良くありません。

// useFormのロジック
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<SubmitDataArgumentType>({
  mode: 'onSubmit',
  criteriaMode: 'all',
  shouldFocusError: false,
});

// エラーバリデーションを直接記述
const nicknameRegister = register('nickname', {
  required: '必須入力のエラーメッセージ',
  min: {
    value: 4,
    message: '最小文字数入力のエラーメッセージ'
  },
  max: {
    value: 25,
    message: '最大文字数入力のエラーメッセージ'
  },
  pattern: {
    value: /[A-Za-z]{3}/,
    message: '正規表現のエラーメッセージ'
  }
});
const passwordRegister = register('password', {
  required: '必須エラー',
  min: {
    value: 4,
    message: '最小文字数入力のエラーメッセージ'
  },
  max: {
    value: 25,
    message: '最大文字数入力のエラーメッセージ'
  },
  pattern: {
    value: /[A-Za-z]{3}/,
    message: '正規表現のエラーメッセージ'
  }
});
const purposeRegister = register('purpose', {
  required: '必須エラー'
});

バリデーションをyupで実装する場合
resolverに対してバリデーションスキーマを渡すだけでokなので、logic.tsの内容が汚れず、可読性が良い状態になります。

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<SubmitDataArgumentType>({
  mode: 'onSubmit',
  criteriaMode: 'all',
  shouldFocusError: false,
  resolver: yupResolver(errorScheme), // logic.ts側はresolverの記述のみでok
});

実際のerrorScheme.tsの内容は、以下の様な感じです。
最低限の必須チェック、最小・最大文字数チェック、正規表現チェックのみを行い、そこからあふれるAPIとの接続が必要になってくるエラーチェックは、logic.ts側で記述する形になります。
※実際のファイルは、定数で記述していますが、わかりやすいように数値、文字列をそのまま記述しています。

import * as yup from 'yup';
import { ERROR_MESSAGE, REGEXP } from 'constant';
import { LOCAL_CONSTANT } from './localConstant';

export const errorScheme = yup.object().shape({
  nickname:
    yup
    .string()
    .required('必須入力のエラーメッセージ')
    .matches(
      /^[a-zA-Z0-9_-]*$/,
      '正規表現のエラーメッセージ'
    )
    .min(
      4,
      '最小文字数のエラーメッセージ'
    )
    .max(
      25,
      '最大文字数のエラーメッセージ'
    ),
  password:
    yup
    .string()
    .required('必須入力のエラーメッセージ')
    .matches(
      /^[a-zA-Z0-9!-/:-@¥[-`{-~]*$/,
      '正規表現のエラーメッセージ'
    )
    .min(
      8,
      '最小文字数のエラーメッセージ'
    )
    .max(
      32,
      '最大文字数のエラーメッセージ'
    ),
  purpose:
    yup
    .mixed()
    .nullable()
    .required('必須入力のエラーメッセージ'),
});

特に何も考えない場合だと、index.tsxになんでもまとめがちですが、それぞれのファイルで責務を分けることで、保守性が向上したり、複数人開発しやすかったりといったことが期待できるので、個人的にはファイルが肥大化してきたな。。と感じたら、どんどんファイルを分けるのがオススメです。

この辺りは、ベストなパターンが色々あると思いますので、試行錯誤しながら知識を随時アップデートしていけたらと思っています。

Discussion