ReactアプリにReact-Redux、React-ToolKit、Yupを使ったフォームを作ってみます。
完成イメージ
手順
必要なモジュールは、下記のとおりです。
1.React-Reduxをインストール
2.Redux-Toolkitをインストール
3.@hookform/resolversをインストール
4.yupをインストール
React-ReduxとRedux-Toolkitをインストール
まずは下記をインストールします。
// React-Reduxをインストール
npm install react-redux
// React-ToolKitをインストール
npm install @reduxjs/toolkit
React-Redux公式サイト
Redux-ToolKit公式サイト
react-redux
@reduxjs/toolkit
@hookform/resolversとyupをインストール
// @hookform/resolversをインストール
npm i @hookform/resolvers
// yupをインストール
npm i yup
@hookform/resolvers
Yup
React-Reduxを使って実装する
1.Reduxストア設定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でインポートしなければならない」ルールが強制されます。
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)
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. フォームコンポーネント作成
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)
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()はReduxの useDispatch / useSelector をラップしています。
これらを使うには、コンポーネントツリーの上位に Provider でReduxストアを渡す必要があります。
Provider でアプリ全体をラップしましょう。
5.Redux Provider設定 (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 コンポーネントがこうなっているとします👇
<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 を上書きしてReduxにdispatchしてしまっているため、
Yup に渡る値が更新されず、バリデーションが動かないんです。
✅ 解決方法1(最もシンプル・正しいやり方)
フォームの値管理は react-hook-form に一本化 し、
Redux には submit 時にだけ dispatch するようにします。
つまり、value と onChange の両方を削除して、こう書き換えます👇
<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 に反映します:
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}`);
};
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 のエラーメッセージ(例:「メールの形式が正しくありません。」)が表示されます。
サイト


