State Management in Yup: Patterns and Libraries
Yup is a schema builder for runtime value parsing and validation. While Yup itself is not a state management library, it plays a central role in managing validation state across modern applications, especially forms. This tutorial explores how Yup fits into the broader state management picture, the patterns developers use to keep validation state in sync with application state, and the libraries that make this integration seamless.
What Is Validation State?
Validation state refers to the set of information that describes the current validity of user input: which fields are valid, which have errors, what those error messages are, whether a field has been touched, and whether a submission is in progress. In a typical form, this state must be tracked alongside the actual field values.
Yup does not store this state. Instead, it provides a declarative schema that, when run against a value, produces a validation result. The surrounding application is responsible for storing values, triggering validation, and rendering errors. This separation is what makes Yup composable with virtually any state management approach.
Why It Matters
- Single source of truth: A Yup schema becomes the canonical definition of what "valid" means, eliminating duplicated validation logic across frontend and backend.
- Performance: Naive validation strategiesâvalidating every field on every keystrokeâcan degrade UX. Smart state management patterns avoid this.
- Developer experience: Integrating Yup with the right state management library reduces boilerplate and prevents subtle bugs like stale errors.
- Testability: Schemas can be unit tested independently of the UI layer.
Core Yup Concepts Recap
Before diving into state patterns, here is a minimal Yup schema that we will reuse throughout the tutorial:
import * as yup from 'yup';
const userSchema = yup.object({
name: yup.string().required('Name is required').min(2, 'Too short'),
email: yup.string().email('Invalid email').required('Email is required'),
age: yup.number().positive().integer().required(),
});
Calling userSchema.validate(values) returns a promise that resolves with the cast values or rejects with a ValidationError. Calling userSchema.validateSync(values) does the same synchronously. For form state, however, you usually want all errors at once, not just the first one.
try {
const valid = await userSchema.validate(
{ name: 'A', email: 'bad' },
{ abortEarly: false }
);
} catch (err) {
// err.inner is an array of ValidationError, one per field
const fieldErrors = err.inner.reduce((acc, e) => {
if (e.path && !acc[e.path]) acc[e.path] = e.message;
return acc;
}, {});
console.log(fieldErrors);
// { name: 'Too short', email: 'Invalid email', age: 'age is a required field' }
}
The abortEarly: false option is the foundation of most Yup-based form state patterns: it lets you collect every field error in a single pass.
Pattern 1: Manual State with useState
For small forms, you can manage values and errors directly with React's useState. This pattern is verbose but transparentâthere is no hidden magic.
import { useState } from 'react';
import * as yup from 'yup';
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
export default function LoginForm() {
const [values, setValues] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
async function validateField(name, value) {
try {
await schema.validateAt(name, { [name]: value });
setErrors((prev) => ({ ...prev, [name]: undefined }));
} catch (err) {
setErrors((prev) => ({ ...prev, [name]: err.message }));
}
}
function handleChange(e) {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
if (touched[name]) validateField(name, value);
}
function handleBlur(e) {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
validateField(name, values[name]);
}
async function handleSubmit(e) {
e.preventDefault();
try {
await schema.validate(values, { abortEarly: false });
setErrors({});
// submit values
} catch (err) {
const fieldErrors = err.inner.reduce((acc, e) => {
acc[e.path] = e.message;
return acc;
}, {});
setErrors(fieldErrors);
setTouched({ email: true, password: true });
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" value={values.email} onChange={handleChange} onBlur={handleBlur} />
{errors.email && touched.email && <span>{errors.email}</span>}
<input name="password" type="password" value={values.password} onChange={handleChange} onBlur={handleBlur} />
{errors.password && touched.password && <span>{errors.password}</span>}
<button type="submit">Submit</button>
</form>
);
}
This pattern illustrates the three pieces of validation stateâvalues, errors, and touchedâand the two trigger points: onBlur for first validation and onChange for revalidation once a field is touched. Most higher-level libraries automate exactly this flow.
Pattern 2: useReducer for Centralized Form State
As forms grow, scattering useState calls becomes hard to maintain. A useReducer approach centralizes all validation state and transitions in one place.
import { useReducer, useCallback } from 'react';
import * as yup from 'yup';
const initialState = {
values: { email: '', password: '' },
errors: {},
touched: {},
isSubmitting: false,
};
function formReducer(state, action) {
switch (action.type) {
case 'SET_VALUE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
};
case 'SET_TOUCHED':
return {
...state,
touched: { ...state.touched, [action.field]: true },
};
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'CLEAR_ERROR':
return { ...state, errors: { ...state.errors, [action.field]: undefined } };
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_END':
return { ...state, isSubmitting: false };
default:
return state;
}
}
export function useFormWithYup(schema) {
const [state, dispatch] = useReducer(formReducer, initialState);
const validateAll = useCallback(async (values) => {
try {
await schema.validate(values, { abortEarly: false });
dispatch({ type: 'SET_ERRORS', errors: {} });
return true;
} catch (err) {
const errors = err.inner.reduce((acc, e) => {
acc[e.path] = e.message;
return acc;
}, {});
dispatch({ type: 'SET_ERRORS', errors });
return false;
}
}, [schema]);
const setValue = useCallback((field, value) => {
dispatch({ type: 'SET_VALUE', field, value });
}, []);
const setTouched = useCallback((field) => {
dispatch({ type: 'SET_TOUCHED', field });
}, []);
return { state, setValue, setTouched, validateAll, dispatch };
}
This reducer-based pattern is the building block for most custom form hooks. It keeps validation state predictable and testable, and it scales better than ad-hoc useState calls.
Pattern 3: Context-Based Validation State
When validation state must be shared across deeply nested componentsâsuch as a multi-step wizardâReact Context is a natural fit. The Yup schema lives at the provider level, and child components consume validation state via a hook.
import { createContext, useContext, useMemo, useState } from 'react';
import * as yup from 'yup';
const FormContext = createContext(null);
export function FormProvider({ schema, initialValues, children }) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const api = useMemo(() => ({
values,
errors,
touched,
setField: (name, value) => {
setValues((prev) => ({ ...prev, [name]: value }));
},
validateField: async (name) => {
try {
await schema.validateAt(name, values);
setErrors((prev) => ({ ...prev, [name]: undefined }));
} catch (err) {
setErrors((prev) => ({ ...prev, [name]: err.message }));
}
},
validateAll: async () => {
try {
await schema.validate(values, { abortEarly: false });
setErrors({});
return true;
} catch (err) {
const next = err.inner.reduce((acc, e) => {
acc[e.path] = e.message;
return acc;
}, {});
setErrors(next);
return false;
}
},
markTouched: (name) => setTouched((prev) => ({ ...prev, [name]: true })),
}), [values, errors, touched, schema]);
return <FormContext.Provider value={api}>{children}</FormContext.Provider>;
}
export function useForm() {
const ctx = useContext(FormContext);
if (!ctx) throw new Error('useForm must be used within FormProvider');
return ctx;
}
Child components can now read errors and dispatch updates without prop drilling:
function EmailField() {
const { values, errors, touched, setField, markTouched, validateField } = useForm();
return (
<>
<input
value={values.email}
onChange={(e) => setField('email', e.target.value)}
onBlur={() => { markTouched('email'); validateField('email'); }}
/>
{touched.email && errors.email && <p>{errors.email}</p>}
</>
);
}
Libraries That Integrate Yup with State Management
React Hook Form
React Hook Form (RHF) is the most popular choice for managing form state with Yup. RHF handles values, touched, errors, and submission, while Yup provides the validation rules. The bridge is the @hookform/resolvers package.
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
const schema = yup.object({
username: yup.string().required().min(3),
password: yup.string().required().min(8),
});
export default function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: yupResolver(schema),
mode: 'onBlur',
});
const onSubmit = async (data) => {
// data is already validated and cast by Yup
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <p>{errors.username.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isSubmitting}>Sign up</button>
</form>
);
}
RHF uses uncontrolled inputs by default, which means it does not re-render the whole form on every keystroke. The mode: 'onBlur' option controls when Yup runs, balancing responsiveness and performance.
Formik
Formik is another widely used library. It manages values, errors, touched, and submission, and accepts a validationSchema prop that takes a Yup schema directlyâno extra resolver needed.
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as yup from 'yup';
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
export default function LoginForm() {
return (
<Formik
initialValues={{ email: '', password: '' }}
validationSchema={schema}
onSubmit={async (values, { setSubmitting }) => {
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(values),
});
setSubmitting(false);
}}
>
{({ isSubmitting }) => (
<Form>
<Field type="email" name="email" />
<ErrorMessage name="email" component="div" />
<Field type="password" name="password" />
<ErrorMessage name="password" component="div" />
<button type="submit" disabled={isSubmitting}>Log in</button>
</Form>
)}
</Formik>
);
}
Formik runs validation on change and blur by default, and again on submit. It internally uses schema.validate(values, { abortEarly: false }) and maps the resulting errors into its state.
Zustand with Yup
For application-wide state that includes form data, Zustand is a lightweight option. You can store values in a Zustand store and run Yup validation inside actions, keeping validation logic colocated with state mutations.
import { create } from 'zustand';
import * as yup from 'yup';
const settingsSchema = yup.object({
displayName: yup.string().required().max(40),
notifications: yup.boolean().required(),
});
export const useSettingsStore = create((set, get) => ({
values: { displayName: '', notifications: true },
errors: {},
setField: async (name, value) => {
const next = { ...get().values, [name]: value };
set({ values: next });
try {
await settingsSchema.validateAt(name, next);
set((s) => ({ errors: { ...s.errors, [name]: undefined } }));
} catch (err) {
set((s) => ({ errors: { ...s.errors, [name]: err.message } }));
}
},
validateAll: async () => {
try {
await settingsSchema.validate(get().values, { abortEarly: false });
set({ errors: {} });
return true;
} catch (err) {
const errors = err.inner.reduce((acc, e) => {
acc[e.path] = e.message;
return acc;
}, {});
set({ errors });
return false;
}
},
}));
Components subscribe to slices of this store, and validation runs as a side effect of state updates. This pattern works well when the same data drives both the form and other parts of the UI.
Redux Toolkit and Yup
In Redux-based applications, Yup validation typically belongs in thunks or component-level handlers rather than reducers, because reducers should remain pure and synchronous. A common pattern is to validate before dispatching a success action.
import { createAsyncThunk } from '@reduxjs/toolkit';
import * as yup from 'yup';
const checkoutSchema = yup.object({
address: yup.string().required(),
city: yup.string().required(),
zip: yup.string().matches(/^\d{5}$/, 'Invalid ZIP').required(),
});
export const submitCheckout = createAsyncThunk(
'checkout/submit',
async (values, { rejectWithValue }) => {
try {
const valid = await checkoutSchema.validate(values, { abortEarly: false });
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify(valid),
});
if (!res.ok) throw new Error('Server error');
return await res.json();
} catch (err) {
if (err.name === 'ValidationError') {
const errors = err.inner.reduce((acc, e) => {
acc[e.path] = e.message;
return acc;
}, {});
return rejectWithValue({ validationErrors: errors });
}
return rejectWithValue({ serverError: err.message });
}
}
);
The slice can then store validationErrors from the rejected action and surface them in the UI.
Advanced Patterns
Conditional and Dependent Validation
Real forms often have fields whose validation depends on other fields. Yup's when method lets you express this, and the state management layer simply re-runs validation when dependencies change.
const schema = yup.object({
type: yup.string().oneOf(['individual', 'company']).required(),
// company name required only when type === 'company'
companyName: yup.string().when('type', {
is: 'company',
then: (s) => s.required('Company name is required'),
otherwise: (s) => s.notRequired(),
}),
});
Lazy Schemas for Dynamic Forms
When the schema itself depends on runtime values, use yup.lazy. This is useful for forms whose fields are determined by server data.
const dynamicSchema = yup.lazy((values) => {
const shape = { kind: yup.string().required() };
if (values.kind === 'email') {
shape.address = yup.string().email().required();
} else if (values.kind === 'phone') {
shape.address = yup.string().matches(/^\+?\d{6,15}$/).required();
}
return yup.object(shape);
});
Debounced Validation
Validating on every keystroke can be expensive for schemas with async rules (for example, checking username availability). Debouncing validation keeps the UI responsive.
import { useEffect, useRef } from 'react';
function useDebouncedValidation(value, validate, delay = 300) {
const timer = useRef();
useEffect(() => {
clearTimeout(timer.current);
timer.current = setTimeout(() => validate(value), delay);
return () => clearTimeout(timer.current);
}, [value, validate, delay]);
}
Best Practices
- Keep schemas separate from components. Define Yup schemas in their own modules so they can be shared with the backend or reused across forms.
- Always use
abortEarly: falsewhen collecting errors for form display, so users see all problems at once. - Validate on blur first, then on change. This avoids showing errors before the user has finished typing, while still giving immediate feedback afterward.
- Cast values after validation. Yup's
validatereturns cast values (for example, strings to numbers). Use the returned value, not the raw input, for submission. - Avoid storing derived state. Errors are derived from values plus the schema. Recompute them rather than trying to keep them manually in sync.
- Test schemas independently. Yup schemas are pure functions of input to result, which makes them trivial to unit test without rendering components.
- Choose the right library for the scale. For a two-field login form,
useStateis enough. For complex forms with dynamic fields, reach for React Hook Form or Formik. For app-wide state, integrate Yup into Zustand or Redux Toolkit. - Handle async validation carefully. Network-based rules should be debounced and cancellable to avoid race conditions where an older response overwrites a newer one.
Conclusion
Yup's role in state management is narrow but essential: it is the authority on what constitutes valid data, while the surrounding state layerâwhether useState, a custom reducer, React Context, React Hook Form, Formik, Zustand, or Redux Toolkitâowns the values, errors, and touched flags that the UI renders. By understanding the boundary between Yup and state management, and by applying the patterns in this tutorial, you can build forms that are predictable, performant, and easy to maintain. The key is to treat the Yup schema as a single source of truth for validation rules and to let your state management library handle the mechanics of tracking when and how those rules are evaluated.