← Back to DevBytes

State Management in Yup: Patterns and Libraries

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

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

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.

🛠 Tools from DevBytes

Inventory Tracker Pro — Excel inventory system, low-stock alerts · $19
AI Dev Kit for Mac — local AI dev environment templates · $9.99
KeyMapper for Mac — custom keyboard shortcut toolkit · $7.99

← Back to all articles