0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】ChakraUIにRedux、ToolKit、検証ライブラリYupを組み込んだフォーム画面を作る

Last updated at Posted at 2025-11-08

ReactアプリにReact-ReduxReact-ToolKitYupを使ったフォームを作ってみます。

完成イメージ

image.png

image.png

image.png

手順

必要なモジュールは、下記のとおりです。
1.React-Reduxをインストール
2.Redux-Toolkitをインストール
3.@hookform/resolversをインストール
4.yupをインストール

React-ReduxRedux-Toolkitをインストール

まずは下記をインストールします。

// React-Reduxをインストール
npm install react-redux

// React-ToolKitをインストール
npm install @reduxjs/toolkit

React-Redux公式サイト

Redux-ToolKit公式サイト

react-redux

@reduxjs/toolkit

@hookform/resolversyupをインストール

// @hookform/resolversをインストール
npm i @hookform/resolvers

// yupをインストール
npm i yup

@hookform/resolvers

Yup

React-Reduxを使って実装する

1.Reduxストア設定src/app/store.ts

src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '@/features/user/userSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

TypeScript 5.x 以降の新しい挙動で、
"verbatimModuleSyntax": true tsconfig.jsonに設定されていると、
「型はimport typeでインポートしなければならない」ルールが強制されます。

src/app/hook.ts
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

✅ ポイント

TypedUseSelectorHook は「型」なのでimport typeで読み込む必要があります。

useDispatch やuseSelectorは「値(関数)」なので、通常の import です。

2.Slice作成(src/features/user/userSlice.ts)

src/features/user/userSlice.ts
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';

interface UserState {
  name: string;
  email: string;
  contentArea: string;
}

const initialState: UserState = {
  name: '',
  email: '',
  contentArea: '',
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    setName: (state, action: PayloadAction<string>) => {
      state.name = action.payload;
    },
    setEmail: (state, action: PayloadAction<string>) => {
      state.email = action.payload;
    },
    setContentArea: (state, action: PayloadAction<string>) => {
      state.contentArea = action.payload;
    },
    resetUser: (state) => {
      state.name = '';
      state.email = '';
      state.contentArea = '';
    },
  },
});

export const { setName, setEmail, setContentArea, resetUser } = userSlice.actions;
export default userSlice.reducer;

3. フォームコンポーネント作成

src/chakraComponents/ui/fieldSet.tsx
import {
  Button,
  Field,
  Fieldset,
  For,
  Input,
  NativeSelect,
  Stack,
  Group,
  Textarea,
  Flex,
} from '@chakra-ui/react';
import { Tooltip } from '@/components/ui/tooltip'; //ツールチップのコンポーネント
import { useAppSelector, useAppDispatch } from '@/app/hooks';
import { setName, resetUser } from '@/features/user/userSlice';
import * as yup from 'yup'; //検証ライブラリyup
import { yupResolver } from '@hookform/resolvers/yup'; //検証ライブラリ「Yup」を使用するためのモジュール
import { useForm } from 'react-hook-form'; //追加

const schema = yup.object({
  name: yup.string().required('名前は入力必須です。').trim().min(1, '空白のみは不可です。'),
  email: yup
    .string()
    .required('メールアドレスは入力必須です。')
    .email('メールの形式が正しくありません。'),
  contentArea: yup
    .string()
    .required('入力内容欄は入力必須です。')
    .min(10, '少なくとも10文字以上入力してください。')
    .max(100, '100文字以内で入力してください。'),
});

type FormValues = {
  name: string;
  email: string;
  contentArea: string;
};

const FieldsetComponent = () => {
  const dispatch = useAppDispatch();
  const { name, email, contentArea } = useAppSelector((state) => state.user);

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: yupResolver(schema),
    defaultValues: { name, email, contentArea },
  });

  const onSubmit = (data: FormValues) => {
    dispatch(setName(data.name));
    dispatch(setName(data.email));
    dispatch(setName(data.contentArea));
    alert(`送信しました:\n名前: ${data.name}\nメール: ${data.email}\n内容:${data.contentArea}`);
  };

  const handleReset = () => {
    reset({ name: '', email: '', contentArea: '' }); //フォームの値をクリア
    dispatch(resetUser()); //Reduxの状態も初期化する
  };

  return (
    <Flex
      justify="center" // 水平方向中央
      align="center" // 垂直方向中央
      minH="100vh" // ビューポート全体の高さ確保
      backgroundColor="gray.100" // 背景色(任意)
    >
      <form onSubmit={handleSubmit(onSubmit)}>
        <Fieldset.Root size="lg" maxW="md">
          <Stack>
            <Fieldset.Legend>お問い合わせフォーム</Fieldset.Legend>
            <Fieldset.HelperText>以下の項目をご入力ください。</Fieldset.HelperText>
          </Stack>

          <Fieldset.Content>
            <Field.Root>
              <Field.Label>お名前(Name)</Field.Label>
              <Input
                {...register('name')}
                borderColor="black"
                _hover={{ borderColor: 'black' }}
                focusRingColor="black"
              />
              {errors.name && (
                <span style={{ color: 'red', fontSize: '0.9rem' }}>{errors.name.message}</span>
              )}
            </Field.Root>

            <Field.Root>
              <Field.Label>メールアドレス(Email address)</Field.Label>
              <Input
                type="email"
                {...register('email')}
                borderColor="black"
                _hover={{ borderColor: 'black' }}
                focusRingColor="black"
              />
              {errors.email && (
                <span style={{ color: 'red', fontSize: '0.9rem' }}>{errors.email.message}</span>
              )}
            </Field.Root>

            <Field.Root>
              <Field.Label>国名</Field.Label>
              <NativeSelect.Root>
                <NativeSelect.Field
                  name="country"
                  borderColor="black"
                  _hover={{ borderColor: 'black' }}
                  focusRingColor="black"
                >
                  <For each={['日本', '米国', 'その他']}>
                    {(item) => (
                      <option key={item} value={item}>
                        {item}
                      </option>
                    )}
                  </For>
                </NativeSelect.Field>
                <NativeSelect.Indicator />
              </NativeSelect.Root>
            </Field.Root>
            <Field.Root>
              <Field.Label>お問い合わせ内容</Field.Label>
              <Textarea
                placeholder="お問い合わせ内容をお書きください。"
                borderColor="black"
                _hover={{ borderColor: 'black' }}
                focusRingColor="black"
                {...register('contentArea')}
              />
              {errors.contentArea && (
                <span style={{ color: 'red', fontSize: '0.9rem' }}>
                  {errors.contentArea.message}
                </span>
              )}
            </Field.Root>
          </Fieldset.Content>

          {/*Groupコンポーネントでボタンを横並び尾設定*/}
          <Group>
            {/*ツールチップ設定*/}
            <Tooltip content="登録確認画面に移動します。">
              <Button
                type="submit"
                backgroundColor="blue.500"
                _hover={{ backgroundColor: 'blue.700' }}
                alignSelf="flex-start"
              >
                登録
              </Button>
            </Tooltip>

            {/*ツールチップ設定*/}
            <Tooltip
              showArrow
              content="前の画面に戻ります"
              contentProps={{ css: { '--tooltip-bg': 'tomato' } }}
            >
              <Button type="button" alignSelf="flex-start">
                戻る
              </Button>
            </Tooltip>
            <Tooltip
              showArrow
              content="入力値をクリアします"
              contentProps={{ css: { '--tooltip-bg': 'lightblue' } }}
            >
              <Button type="button" onClick={handleReset}>
                クリア
              </Button>
            </Tooltip>
          </Group>
        </Fieldset.Root>
      </form>
    </Flex>
  );
};

export default FieldsetComponent;

4. ルート設定 (src/App.tsx)

src/App.tsx
import './App.css';
import FieldsetComponent from './chakraComponents/ui/fieldSet';

function App() {
  return (
    <div>
      <h1 data-testid="app-title">My React App</h1>
      <FieldsetComponent />
    </div>
  );
}

export default App;

useAppDispatch() useAppSelector()ReduxuseDispatch / useSelector をラップしています。
これらを使うには、コンポーネントツリーの上位に Provider Reduxストアを渡す必要があります。

Provider でアプリ全体をラップしましょう。

5.Redux Provider設定 (src/main.tsx)

src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; // ✅ v3はdefaultSystemが必要
import { Provider } from 'react-redux'; //✅追加
import { store } from './app/store'; //✅追加
import App from './App';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    {/* ✅ ReduxのProviderでアプリ全体をラップ */}
    <Provider store={store}>
      <ChakraProvider value={defaultSystem}>
        {/* ✅ ← v3では必須 */}
        <App />
      </ChakraProvider>
    </Provider>
  </React.StrictMode>,
);

気を付けたこと

二重管理(controlled と uncontrolled の競合)を避ける

たとえば、あなたの Input コンポーネントがこうなっているとします👇

sample.tsx
<Input
  type="email"
  {...register('email')}
  value={email}
  onChange={(e) => dispatch(setEmail(e.target.value))}
/>

これは react-hook-form にもReduxにも同時に状態を持たせており、
react-hook-form がバリデーションを検知できなくなっています。

つまり、register が内部でonChange / onBlurを差し込んでいますが、
onChange を上書きしてReduxdispatchしてしまっているため、
Yup に渡る値が更新されず、バリデーションが動かないんです。

✅ 解決方法1(最もシンプル・正しいやり方)

フォームの値管理は react-hook-form に一本化 し、
Redux には submit 時にだけ dispatch するようにします。

つまり、valueonChange の両方を削除して、こう書き換えます👇

sample.tsx
<Input
  type="email"
  {...register('email')}
  borderColor="black"
  _hover={{ borderColor: 'black' }}
  focusRingColor="black"
/>
{errors.email && (
  <span style={{ color: 'red', fontSize: '0.9rem' }}>{errors.email.message}</span>
)}

そして、onSubmit の中でだけ Redux に反映します:

sample.tsx
const onSubmit = (data: FormValues) => {
  dispatch(setName(data.name));
  dispatch(setEmail(data.email));
  dispatch(setContentArea(data.contentArea));

  alert(`送信しました:\n名前: ${data.name}\nメール: ${data.email}\n内容: ${data.contentArea}`);
};

sample.tsx
import {
  Button,
  Field,
  Fieldset,
  Input,
  NativeSelect,
  Stack,
  Group,
  Textarea,
  Flex,
} from '@chakra-ui/react';
import { For } from '@ark-ui/react/for';
import { Tooltip } from '@/components/ui/tooltip';
import { useAppDispatch } from '@/app/hooks';
import { setName, setEmail, setContentArea, resetUser } from '@/features/user/userSlice';
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { useForm } from 'react-hook-form';

const schema = yup.object({
  name: yup.string().required('名前は入力必須です。').trim().min(1, '空白のみは不可です。'),
  email: yup
    .string()
    .required('メールアドレスは入力必須です。')
    .email('メールの形式が正しくありません。'),
  contentArea: yup
    .string()
    .required('入力内容欄は入力必須です。')
    .min(10, '少なくとも10文字以上入力してください。')
    .max(100, '100文字以内で入力してください。'),
});

type FormValues = {
  name: string;
  email: string;
  contentArea: string;
};

const FieldsetComponent = () => {
  const dispatch = useAppDispatch();

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: yupResolver(schema),
    defaultValues: { name: '', email: '', contentArea: '' },
  });

  const onSubmit = (data: FormValues) => {
    dispatch(setName(data.name));
    dispatch(setEmail(data.email));
    dispatch(setContentArea(data.contentArea));
    alert(`送信しました:\n名前: ${data.name}\nメール: ${data.email}\n内容: ${data.contentArea}`);
  };

  const handleReset = () => {
    reset({ name: '', email: '', contentArea: '' });
    dispatch(resetUser());
  };

  return (
    <Flex justify="center" align="center" minH="100vh" backgroundColor="gray.100">
      <form onSubmit={handleSubmit(onSubmit)}>
        <Fieldset.Root size="lg" maxW="md">
          <Stack>
            <Fieldset.Legend>お問い合わせフォーム</Fieldset.Legend>
            <Fieldset.HelperText>以下の項目をご入力ください。</Fieldset.HelperText>
          </Stack>

          <Fieldset.Content>
            <Field.Root>
              <Field.Label>お名前(Name)</Field.Label>
              <Input
                {...register('name')}
                borderColor="black"
                _hover={{ borderColor: 'black' }}
                focusRingColor="black"
              />
              {errors.name && (
                <span style={{ color: 'red', fontSize: '0.9rem' }}>{errors.name.message}</span>
              )}
            </Field.Root>

            <Field.Root>
              <Field.Label>メールアドレス(Email address)</Field.Label>
              <Input
                type="email"
                {...register('email')}
                borderColor="black"
                _hover={{ borderColor: 'black' }}
                focusRingColor="black"
              />
              {errors.email && (
                <span style={{ color: 'red', fontSize: '0.9rem' }}>{errors.email.message}</span>
              )}
            </Field.Root>

            <Field.Root>
              <Field.Label>お問い合わせ内容</Field.Label>
              <Textarea
                {...register('contentArea')}
                placeholder="お問い合わせ内容をお書きください。"
                borderColor="black"
                _hover={{ borderColor: 'black' }}
                focusRingColor="black"
              />
              {errors.contentArea && (
                <span style={{ color: 'red', fontSize: '0.9rem' }}>
                  {errors.contentArea.message}
                </span>
              )}
            </Field.Root>
          </Fieldset.Content>

          <Group>
            <Tooltip content="登録確認画面に移動します。">
              <Button type="submit" colorScheme="blue">
                登録
              </Button>
            </Tooltip>
            <Tooltip content="入力値をクリアします">
              <Button type="button" onClick={handleReset}>
                クリア
              </Button>
            </Tooltip>
          </Group>
        </Fieldset.Root>
      </form>
    </Flex>
  );
};

export default FieldsetComponent;

この形にすれば、
Yup のエラーメッセージ(例:「メールの形式が正しくありません。」)が表示されます。

サイト

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?