โ† Back to DevBytes

State Management in Material UI: Patterns and Libraries

Introduction to State Management in Material UI

Material UI (MUI) is one of the most popular React component libraries, offering a rich set of accessible, customizable UI components. However, MUI itself is primarily a presentation library โ€” it does not prescribe how you should manage application state. This means developers must choose their own state management strategy and integrate it cleanly with MUI components. In this tutorial, we will explore what state management means in the context of Material UI, why it matters, the most common patterns, and the libraries that pair well with MUI.

What Is State Management in Material UI?

State management refers to the way an application stores, updates, and shares data across its component tree. In a Material UI application, state typically falls into three categories:

Because MUI components are controlled by default โ€” meaning they rely on props like value and onChange โ€” the way you manage these states directly affects how clean and maintainable your components become.

Why State Management Matters with Material UI

Without a deliberate state strategy, MUI applications quickly suffer from prop drilling, duplicated logic, and hard-to-test components. Consider a settings page with a Drawer, a Switch for dark mode, and a Select for language. If each of these states lives in a distant parent component, you end up passing callbacks through several layers. A good state management approach keeps components focused on rendering while centralizing logic where it belongs.

Additionally, MUI's theming system depends on a ThemeProvider context. Coordinating theme state with the rest of your application state is a common pain point that a well-chosen pattern solves elegantly.

Pattern 1: Local Component State with useState

For simple, self-contained UI interactions, React's built-in useState hook is often the best choice. There is no need to reach for a global store when a single component owns the state.

Example: Controlling a Dialog Locally

import { useState } from 'react';
import {
  Button,
  Dialog,
  DialogTitle,
  DialogContent,
  DialogActions,
  TextField,
} from '@mui/material';

export default function FeedbackDialog() {
  const [open, setOpen] = useState(false);
  const [message, setMessage] = useState('');

  const handleSubmit = () => {
    console.log('Feedback:', message);
    setMessage('');
    setOpen(false);
  };

  return (
    <>
      <Button variant="contained" onClick={() => setOpen(true)}>
        Give Feedback
      </Button>
      <Dialog open={open} onClose={() => setOpen(false)}>
        <DialogTitle>Share your thoughts</DialogTitle>
        <DialogContent>
          <TextField
            autoFocus
            margin="dense"
            label="Message"
            fullWidth
            multiline
            rows={4}
            value={message}
            onChange={(e) => setMessage(e.target.value)}
          />
        </DialogContent>
        <DialogActions>
          <Button onClick={() => setOpen(false)}>Cancel</Button>
          <Button variant="contained" onClick={handleSubmit}>
            Submit
          </Button>
        </DialogActions>
      </Dialog>
    </>
  );
}

This pattern is ideal when no other component needs access to open or message. It keeps the component self-contained and easy to test.

Pattern 2: Lifting State Up for Shared UI State

When two or more sibling components need the same state, the standard React approach is to lift that state to their common parent. This works well for small subtrees, such as a layout with a persistent Drawer and a toggle button in the AppBar.

Example: Shared Drawer State in a Layout

import { useState } from 'react';
import {
  AppBar,
  Toolbar,
 IconButton,
  Drawer,
  List,
  ListItem,
  ListItemText,
  Box,
  Typography,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';

export default function AppLayout({ children }) {
  const [drawerOpen, setDrawerOpen] = useState(false);

  return (
    <Box sx={{ display: 'flex' }}>
      <AppBar position="fixed">
        <Toolbar>
          <IconButton
            color="inherit"
            edge="start"
            onClick={() => setDrawerOpen(true)}
          >
            <MenuIcon />
          </IconButton>
          <Typography variant="h6">My App</Typography>
        </Toolbar>
      </AppBar>
      <Drawer
        open={drawerOpen}
        onClose={() => setDrawerOpen(false)}
      >
        <List>
          <ListItem button>
            <ListItemText primary="Dashboard" />
          </ListItem>
          <ListItem button>
            <ListItemText primary="Settings" />
          </ListItem>
        </List>
      <Drawer>
      <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
        {children}
      </Box>
    </Box>
  );
}

While lifting state works, it becomes unwieldy when the shared state is needed by deeply nested components. That is where context and external libraries come in.

Pattern 3: React Context for Theme and Global UI State

React's Context API is a natural fit for state that many components across the tree need to read or update. The most common MUI use case is theme mode (light/dark) toggling, but context is also useful for locale, sidebar collapse state, and feature flags.

Example: Theme Mode with Context

import { createContext, useContext, useState, useMemo } from 'react';
import {
  ThemeProvider,
  createTheme,
  CssBaseline,
  Switch,
  FormControlLabel,
} from '@mui/material';

const ThemeModeContext = createContext({
  mode: 'light',
  toggleMode: () => {},
});

export function useThemeMode() {
  return useContext(ThemeModeContext);
}

export function AppProviders({ children }) {
  const [mode, setMode] = useState('light');

  const theme = useMemo(
    () =>
      createTheme({
        palette: {
          mode,
        },
      }),
    [mode]
  );

  const value = useMemo(
    () => ({
      mode,
      toggleMode: () =>
        setMode((prev) => (prev === 'light' ? 'dark' : 'light')),
    }),
    [mode]
  );

  return (
    <ThemeModeContext.Provider value={value}>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        {children}
      </ThemeProvider>
    </ThemeModeContext.Provider>
  );
}

export function ThemeToggle() {
  const { mode, toggleMode } = useThemeMode();
  return (
    <FormControlLabel
      control={<Switch checked={mode === 'dark'} onChange={toggleMode} />}
      label="Dark mode"
    />
  );
}

This pattern keeps theme state in sync with MUI's ThemeProvider and lets any component toggle the mode without prop drilling.

Pattern 4: Form State with React Hook Form

Forms are one of the most state-heavy parts of any MUI application. Managing field values, validation errors, and submission status manually with useState becomes tedious quickly. React Hook Form is a lightweight, performant library that pairs exceptionally well with MUI inputs.

Example: Registration Form with Validation

import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
  Box,
  Button,
  TextField,
  Stack,
  Alert,
} from '@mui/material';

const schema = yup.object({
  email: yup.string().email('Enter a valid email').required('Email is required'),
  password: yup
    .string()
    .min(8, 'Password must be at least 8 characters')
    .required('Password is required'),
});

export default function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: yupResolver(schema),
    defaultValues: { email: '', password: '' },
  });

  const onSubmit = async (data) => {
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 800));
    console.log('Submitted:', data);
  };

  return (
    <Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
      <Stack spacing={2}>
        {errors.root && <Alert severity="error">{errors.root.message}</Alert>}
        <TextField
          label="Email"
          type="email"
          {...register('email')}
          error={!!errors.email}
          helperText={errors.email?.message}
          fullWidth
        />
        <TextField
          label="Password"
          type="password"
          {...register('password')}
          error={!!errors.password}
          helperText={errors.password?.message}
          fullWidth
        />
        <Button
          type="submit"
          variant="contained"
          disabled={isSubmitting}
        >
          {isSubmitting ? 'Submitting...' : 'Register'}
        </Button>
      </Stack>
    </Box>
  );
}

React Hook Form minimizes re-renders by using uncontrolled inputs under the hood, which is especially valuable in large MUI forms with many fields.

Pattern 5: Global Application State with Zustand

When state needs to be shared across many unrelated components โ€” such as a shopping cart, user session, or notification queue โ€” a dedicated store is often cleaner than context. Zustand is a minimal, hook-based state library that integrates seamlessly with MUI without boilerplate.

Example: Notification Store with Snackbar Integration

import { create } from 'zustand';
import { Snackbar, Alert } from '@mui/material';
import { useEffect } from 'react';

const useNotificationStore = create((set) => ({
  open: false,
  message: '',
  severity: 'info',
  showNotification: (message, severity = 'info') =>
    set({ open: true, message, severity }),
  closeNotification: () => set({ open: false }),
}));

export function useNotify() {
  return useNotificationStore((s) => s.showNotification);
}

export function NotificationProvider({ children }) {
  const { open, message, severity, closeNotification } = useNotificationStore();

  return (
    <>
      {children}
      <Snackbar
        open={open}
        autoHideDuration={4000}
        onClose={closeNotification}
        anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
      >
        <Alert
          onClose={closeNotification}
          severity={severity}
          variant="filled"
          sx={{ width: '100%' }}
        >
          {message}
        </Alert>
      </Snackbar>
    </>
  );
}

// Usage in any component:
// const notify = useNotify();
// notify('Item added to cart', 'success');

Zustand lets any component call useNotify() to trigger a Snackbar without threading callbacks through the tree. The store is also easy to test in isolation.

Pattern 6: Server State with React Query

Fetching, caching, and synchronizing data from an API is a distinct concern from UI state. React Query (now part of TanStack Query) handles caching, refetching, loading and error states, and pagination. It pairs naturally with MUI's DataGrid, Table, and Card components.

Example: Loading Users into a DataGrid

import { useQuery } from '@tanstack/react-query';
import { DataGrid } from '@mui/x-data-grid';
import { Box, CircularProgress, Alert } from '@mui/material';

async function fetchUsers() {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json();
}

const columns = [
  { field: 'id', headerName: 'ID', width: 90 },
  { field: 'name', headerName: 'Name', width: 200 },
  { field: 'email', headerName: 'Email', width: 250 },
];

export default function UsersTable() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) {
    return <CircularProgress />;
  }

  if (isError) {
    return <Alert severity="error">{error.message}</Alert>;
  }

  return (
    <Box sx={{ height: 500, width: '100%' }}>
      <DataGrid
        rows={data}
        columns={columns}
        pageSizeOptions={[5, 10, 25]}
        initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
      />
    </Box>
  );
}

By delegating server state to React Query, your MUI components stay focused on presentation while the library handles caching, background refetching, and stale data invalidation.

Pattern 7: Redux Toolkit for Large-Scale Applications

For enterprise applications with complex domain logic, many slices of state, and middleware requirements, Redux Toolkit (RTK) remains a robust choice. RTK reduces historical Redux boilerplate with createSlice and createAsyncThunk, and it works well with MUI when you connect state to component props.

Example: Cart Slice Connected to a Badge

import { createSlice } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';
import { Badge, IconButton } from '@mui/material';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    addItem: (state, action) => {
      state.items.push(action.payload);
    },
    removeItem: (state, action) => {
      state.items = state.items.filter((item) => item.id !== action.payload);
    },
    clearCart: (state) => {
      state.items = [];
    },
  },
});

export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;

export function CartButton() {
  const itemCount = useSelector((state) => state.cart.items.length);
  const dispatch = useDispatch();

  return (
    <IconButton color="inherit" onClick={() => dispatch(addItem({ id: Date.now() }))}>
      <Badge badgeContent={itemCount} color="error">
        <ShoppingCartIcon />
      </Badge>
    </IconButton>
  );
}

Redux Toolkit shines when you need devtools time-travel debugging, middleware for side effects, or a predictable structure that many developers on a team can follow.

Best Practices for State Management with Material UI

Choosing the Right Tool

Selecting a state management approach depends on the scope of your application. For a small dashboard with a few dialogs and a theme toggle, local state plus a single context is sufficient. For a medium-sized app with forms, notifications, and API data, combining React Hook Form, Zustand, and React Query covers most needs without heavy infrastructure. For large enterprise applications with complex workflows, Redux Toolkit provides the structure and tooling a larger team benefits from. The key is to match the tool to the problem rather than forcing a single solution everywhere.

Conclusion

State management in Material UI applications is not about picking one library and using it for everything โ€” it is about recognizing the different kinds of state your app contains and choosing the right tool for each. Local UI interactions thrive with useState, shared UI state fits naturally in React Context, forms are best handled by React Hook Form, global client state is elegantly managed by Zustand or Redux Toolkit, and server data belongs in React Query. By combining these patterns thoughtfully and following best practices around controlled components, selectors, and separation of concerns, you can build Material UI applications that are both visually polished and architecturally sound. The result is a codebase where components remain focused on rendering, state logic stays testable, and scaling to new features feels natural rather than painful.

๐Ÿ›  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