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:
- UI state: Drawer open/close, dialog visibility, tab selection, theme mode (light/dark), and form field focus.
- Form state: Values, errors, touched flags, and validation status for inputs inside MUI
TextField,Select,Checkbox, andAutocompletecomponents. - Domain/server state: Data fetched from APIs, such as a list of users rendered in a
DataGridorTable.
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
- Start local, then escalate. Use
useStatefor component-local UI state. Only introduce context or a store when multiple components genuinely need the same state. - Separate UI state from server state. Use React Query or RTK Query for API data, and reserve Zustand or Redux for client-side state. Mixing them leads to duplicated caches and stale bugs.
- Keep MUI components controlled. Always pass
valueandonChangefrom your state source. This makes components predictable and testable. - Use selectors to prevent unnecessary re-renders. With Zustand, select only the slice of state a component needs. With Redux, use
useSelectorwith narrow selectors and memoize where appropriate. - Co-locate theme state with ThemeProvider. Wrap your app in a single provider that manages both the MUI theme object and the mode toggle, so they never drift out of sync.
- Validate forms with a schema library. Pair React Hook Form with Yup or Zod to keep validation declarative and reusable across MUI form components.
- Avoid storing derived data in state. Compute totals, filtered lists, and formatted values with
useMemorather than storing them in a store. - Name stores and contexts clearly. Names like
useThemeMode,useCartStore, anduseNotifymake it obvious what each piece of state controls.
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.