DEV Community

kensaadi
kensaadi

Posted on

Stop Writing Form Glue Code: MUI Components Already Connected to React Hook Form

If you've used React Hook Form with Material UI, you know the pattern.

It works well.
It's flexible.
But it's also… repetitive.

You're not really building forms.

You're wiring things together.

The Pattern We All Write

A simple input with RHF + MUI usually looks like this:

import { Controller } from 'react-hook-form';
import { TextField } from '@mui/material';

<Controller
  name="email"
  control={control}
  rules={{ required: 'Email is required' }}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      label="Email"
      error={!!fieldState.error}
      helperText={fieldState.error?.message}
    />
  )}
/>
Enter fullscreen mode Exit fullscreen mode

Nothing wrong here.

But now multiply that by:

  • 5 fields
  • 10 fields
  • 20 fields

And you start noticing something:

You're repeating the same integration logic over and over again.


The Real Issue: Integration, Not the Libraries

Material UI gives you great UI components.
React Hook Form gives you great form state.

But neither of them knows about the other.

So every time, you write the glue:

  • connect value / onChange
  • map errors
  • pass validation rules
  • keep UI consistent

That glue becomes your form.


What If Components Already Knew RHF?

Instead of connecting MUI to RHF manually…

What if your components were already connected?

That's what dashforge-ui does.

A TextField from dashforge-ui is still visually a MUI component — but it already understands React Hook Form.

So instead of this:

<Controller
  name="email"
  control={control}
  rules={{ required: 'Email is required' }}
  render={({ field, fieldState }) => (
    <TextField {...field} error={!!fieldState.error} />
  )}
/>
Enter fullscreen mode Exit fullscreen mode

You write this:

import { TextField } from '@dashforge/ui';

<TextField
  name="email"
  label="Email"
  rules={{ required: 'Email is required' }}
/>
Enter fullscreen mode Exit fullscreen mode

Same behavior.
Same validation.
Same form state.

But no glue code.


Important: RHF Is Still There

This is not replacing React Hook Form.

It's built on top of it.

  • form state → handled by RHF
  • validation → handled by RHF
  • performance → same RHF behavior

You're just not wiring it manually anymore.

Think of it as RHF + MUI, already connected.


What Changes in a Real Form

Before (RHF + MUI Vanilla)

import { useForm, Controller } from 'react-hook-form';
import { TextField, Button, Box } from '@mui/material';

interface LoginForm {
  email: string;
  password: string;
}

export function LoginForm() {
  const { control, handleSubmit, formState: { errors } } = useForm<LoginForm>({
    defaultValues: { email: '', password: '' },
  });

  return (
    <Box component="form" onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        name="email"
        control={control}
        rules={{ required: 'Email is required' }}
        render={({ field, fieldState: { error } }) => (
          <TextField
            {...field}
            label="Email"
            error={!!error}
            helperText={error?.message}
            margin="normal"
            fullWidth
          />
        )}
      />

      <Controller
        name="password"
        control={control}
        rules={{ required: 'Password is required' }}
        render={({ field, fieldState: { error } }) => (
          <TextField
            {...field}
            label="Password"
            type="password"
            error={!!error}
            helperText={error?.message}
            margin="normal"
            fullWidth
          />
        )}
      />

      <Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
        Login
      </Button>
    </Box>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's 52 lines for 2 fields.

After (dashforge-ui)

import { DashForm, TextField, Button } from '@dashforge/ui';

type LoginForm = {
  email: string;
  password: string;
};

export function LoginForm() {
  return (
    <DashForm<LoginForm>
      defaultValues={{ email: '', password: '' }}
      onSubmit={(values) => console.log(values)}
    >
      <TextField
        name="email"
        label="Email"
        rules={{ required: 'Email is required' }}
      />

      <TextField
        name="password"
        label="Password"
        type="password"
        rules={{ required: 'Password is required' }}
      />

      <Button type="submit">
        Login
      </Button>
    </DashForm>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's 22 lines. 58% less code.

No Controller.
No manual mapping.
No repetition.


Handling Real Complexity

Because it still uses RHF under the hood, you keep all the power.

Conditional Fields

<TextField
  name="company"
  label="Company"
  visibleWhen={(engine) => {
    const accountType = engine.getNode('accountType')?.value;
    return accountType === 'business';
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Cross-Field Validation

<TextField
  name="confirmPassword"
  label="Confirm Password"
  rules={{
    validate: (value, values) =>
      value === values.password || 'Passwords do not match',
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Async Validation

<TextField
  name="email"
  label="Email"
  rules={{
    validate: async (value) => {
      const isAvailable = await checkEmailAvailability(value);
      return isAvailable ? true : 'Email already registered';
    },
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Same flexibility.
Less boilerplate.


Performance & Bundle Size Impact

  • React Hook Form: ~8.5kB (gzipped)
  • MUI TextField + Button: ~20kB (gzipped)
  • dashforge-ui core: ~12kB (gzipped)

Total with dashforge-ui: ~40.5kB (vs ~65kB with manual RHF + MUI setup)

The integration is actually lighter because dashforge-ui eliminates the Controller overhead and consolidates common patterns.


Developer Experience: Before vs After

Task RHF + MUI dashforge-ui
Add a required text field 15 lines (Controller wrapper) 4 lines
Add email validation 5 lines (rules + helperText mapping) 1 rule object
Add conditional visibility ~20 lines (JSX conditional) 1 visibleWhen function
Type your form data Manual interface + wiring Inferred from form props
Error handling Manual fieldState mapping Built-in, automatic
Form submission Manual handleSubmit + wiring Built-in onSubmit

When to Use dashforge-ui

✅ Perfect For:

  • CRUD forms (create/edit resources)
  • Medium to large forms (8+ fields)
  • Multi-step forms with conditional logic
  • Rapid prototyping where speed matters
  • Teams wanting form consistency across projects
  • React + TypeScript shops that value developer experience

⚠️ Consider alternatives if:

  • You need completely custom field rendering everywhere (dashforge-ui supports this, but with more config)
  • Your form is a single-field micro-interaction (vanilla RHF + MUI might be simpler)
  • You need drag-and-drop form builders or visual design tools

Why This Matters

This is not about saving a few lines of code.

It's about removing an entire category of repetitive work:

  • no more Controller wrappers
  • no more error plumbing
  • no more field wiring

You focus on:

  • the structure of your form
  • the logic between fields
  • the user experience

Getting Started

npm install @dashforge/ui @dashforge/forms @dashforge/ui-core react-hook-form @mui/material @emotion/react @emotion/styled
Enter fullscreen mode Exit fullscreen mode

Full documentation: dashforge-ui.com

Minimal Example

import { DashForm, TextField, Button } from '@dashforge/ui';

export function ContactForm() {
  return (
    <DashForm
      onSubmit={(values) => {
        fetch('/api/contact', {
          method: 'POST',
          body: JSON.stringify(values),
        });
      }}
    >
      <TextField
        name="name"
        label="Name"
        rules={{ required: 'Name is required' }}
      />

      <TextField
        name="email"
        label="Email"
        type="email"
        rules={{
          required: 'Email is required',
          pattern: { value: /^\S+@\S+$/, message: 'Invalid email' },
        }}
      />

      <TextField
        name="message"
        label="Message"
        multiline
        rows={4}
        rules={{ required: 'Message is required' }}
      />

      <Button type="submit">
        Send
      </Button>
    </DashForm>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fully typed. Validated. Styled with MUI. No wiring required.


Final Thought

React Hook Form is great.
Material UI is great.

But the integration between them?

That's where most of the friction lives.

dashforge-ui removes that friction — by giving you components that already understand both.

No layer on top. No new paradigm to learn. Just RHF + MUI, finally connected at the framework level.


Beyond Simple Forms: Managing Field Dependencies

Real-world forms have dependencies between fields.

Examples:

  • Show a "Shipping Address" section only if the user selects "Ship to different address"
  • Validate a "Confirm Password" field against the password value
  • Populate a "City" dropdown based on the selected "Country"
  • Disable a "Submit" button if dependent validations haven't passed

With vanilla RHF + MUI, these become spaghetti code.

How dashforge-ui Handles Dependencies

Field visibility is straightforward with visibleWhen:

<TextField
  name="country"
  label="Country"
  rules={{ required: true }}
/>

{/* Only renders if country === 'US' */}
<TextField
  name="state"
  label="State"
  visibleWhen={(engine) => engine.getNode('country')?.value === 'US'}
/>
Enter fullscreen mode Exit fullscreen mode

For more complex logic — conditional validation rules, async operations triggered by field changes, dynamic options loading — dashforge-ui uses Reactions.

A Reaction is simple:

const reactions = [
  {
    id: 'load-cities-on-country-change',
    watch: ['country'],  // Watch for country changes
    when: (ctx) => Boolean(ctx.getValue('country')),  // Only run if country has a value
    run: async (ctx) => {
      const country = ctx.getValue<string>('country');
      const cities = await fetchCities(country);
      // Update runtime state (not form values)
      ctx.setRuntime('city', { options: cities });
    }
  }
];

<DashForm reactions={reactions}>
  {/* fields here */}
</DashForm>
Enter fullscreen mode Exit fullscreen mode

Think of Reactions as "When this happens, do that" — they watch field changes and run side effects.

This is the real power behind complex form dependencies: instead of manually wiring useWatch + useEffect chains, you describe what triggers what at the form level.

Want to go deeper? I've written a detailed guide on this exact problem:

📖 Building Dependent Form Fields in React: A Practical Approach

That guide covers dependency patterns, performance implications, and real-world solutions. dashforge-ui integrates those patterns into the component model.


👉 If you're using React Hook Form today, I'm curious: how much of your form code is actual logic vs wiring?

Learn more: dashforge-ui.com

GitHub: github.com/dashforge-ui

Deep dive on dependencies: Building Dependent Form Fields in React

Top comments (0)