· 10 min read
Treating React Hooks Like Backend Entities
Feature hooks let you treat React hooks as a frontend application service layer — coordinating data fetching, validation, permissions, forms, and commands in one place.
Most React apps start with a simple pattern: a component calls useQuery, renders some UI, maybe a button calls useMutation. It works fine for small features. Then you add form state. Then validation. Then permission checks. Before long, a single “page” component is juggling four different libraries, scattered state, and inline logic that’s hard to test and harder to reuse.
Here’s the insight: your hooks should behave like backend entities. A useMovie hook should be the single source of truth for everything related to “the movie feature” — data queries, form management, validation, permissions, commands, and view-model mapping. It becomes the orchestration layer between UI and API, much like an Application Service in Domain-Driven Design.
TL;DR:
- One
use{Entity}per aggregate root — CRUD, permissions, form state, ViewModel mapping in one place - ViewModel translates DTOs once — components get
entity.displayRating, not raw API fields - Permissions (CASL) and form state (TanStack Form) live inside the hook — components see booleans like
canUpdateand functions likesaveMovie() - Zero library imports in components — React Query, Zod, Axios never appear there
- UI hooks (
useTheme,useDebounce) are separate — no CRUD or DTOs, just cross-cutting concerns
Let me show you what this looks like.
The Messy Baseline
The common alternative is to scatter concerns across the component:
function MoviePage({ id }: { id: string }) {
const { data: movie, isLoading } = useQuery({
queryKey: ['movie', id],
queryFn: () => fetch(`/api/movies/${id}`).then(r => r.json()),
})
const [title, setTitle] = useState('')
const [rating, setRating] = useState(0)
const updateMutation = useMutation({
mutationFn: (data) => fetch(`/api/movies/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
})
const handleSave = () => { updateMutation.mutate({ title, rating }) }
useEffect(() => {
if (movie) { setTitle(movie.title); setRating(movie.rating) }
}, [movie])
if (isLoading) return <div>Loading...</div>
// render...
}
This component knows about query keys, API URLs, JSON serialization, local form state, effect synchronization, and mutation state. Every new concern (permissions, validation, form library) gets dumped into the same file with no boundary.
The Feature Hook Pattern
The fix is to extract every backend-facing concern into a single hook — what’s sometimes called a feature hook or view-model hook. The component talks only about UI; the hook talks only about orchestration.
Start with a clean API layer:
// api/movieApi.ts
export const movieApi = {
getById: async (id: string): Promise<MovieDTO> => {
const response = await axios.get(`/api/movies/${id}`)
return response.data
},
create: async (request: CreateMovieRequest): Promise<MovieDTO> => {
const response = await axios.post('/api/movies', request)
return response.data
},
update: async (id: string, request: UpdateMovieRequest): Promise<MovieDTO> => {
const response = await axios.put(`/api/movies/${id}`, request)
return response.data
},
delete: async (id: string): Promise<void> => {
await axios.delete(`/api/movies/${id}`)
},
}
Raw API responses rarely match what the UI needs. Fields get renamed, values get formatted, computed properties get derived. Without a boundary, this transformation logic leaks into every component that renders the data — sometimes in different ways each time.
// view-models/movie.ts
export class MovieViewModel {
static map(movie: MovieDTO | undefined) {
if (!movie) return null
return {
id: movie.id,
title: movie.title,
displayRating: `${movie.rating}/10`,
isPopular: movie.rating > 8,
authorId: movie.authorId,
}
}
}
The ViewModel is the single place where DTOs become UI-friendly data. displayRating and isPopular are computed once, in one file, not scattered across components with ad-hoc format logic. Fields the hook needs for logic (like authorId for permission checks) pass through unchanged.
Query keys should follow the same discipline. A factory prevents key collisions and enables targeted cache invalidation:
// query-keys/movies.ts
export const movieKeys = {
all: ['movies'] as const,
byId: (id: string) => ['movies', id] as const,
}
Now the feature hook ties everything together:
// hooks/useMovie.ts
export const useMovie = (id?: string) => {
const { userId, userRole } = useAuth()
const movieQuery = useQuery({
queryKey: movieKeys.byId(id!),
queryFn: () => movieApi.getById(id!),
enabled: !!id,
select: MovieViewModel.map,
})
const createMutation = useMutation({ mutationFn: movieApi.create })
const updateMutation = useMutation({
mutationFn: (request: UpdateMovieRequest) => movieApi.update(id!, request),
})
const deleteMutation = useMutation({ mutationFn: () => movieApi.delete(id!) })
const canCreate = userRole === 'admin' || userRole === 'editor'
const canUpdate = canCreate && !!id
const canDelete = userRole === 'admin' && !!id
return {
movieId: id,
entity: movieQuery.data,
isLoading: movieQuery.isLoading,
movieError: movieQuery.error,
createMovie: createMutation.mutate,
updateMovie: updateMutation.mutate,
deleteMovie: deleteMutation.mutate,
canCreate,
canUpdate,
canDelete,
isSaving: createMutation.isPending || updateMutation.isPending,
isDeleting: deleteMutation.isPending,
saveError: createMutation.error || updateMutation.error,
deleteError: deleteMutation.error,
}
}
The component becomes thin:
function MoviePage({ id }: { id: string }) {
const { entity, isLoading, updateMovie, canUpdate, isSaving } = useMovie(id)
if (isLoading) return <div>Loading...</div>
if (!entity) return <div>Movie not found</div>
return (
<div>
<h1>{entity.title}</h1>
<p>Rating: {entity.displayRating}</p>
<button disabled={!canUpdate || isSaving} onClick={() => updateMovie({ title: 'New Title' })}>
Save
</button>
</div>
)
}
The component knows about entity, canUpdate, updateMovie, and nothing else. No query keys. No API URLs. No DTO fields. No mutation state management.
This Is an Application Service
This pattern maps directly to the backend layers from DDD:
| Backend (DDD) | Frontend (Feature Hook) |
|---|---|
| Controller / UI | Component |
| Application Service | useMovie() hook |
| Repository | movieApi.ts |
| DTO | API response types |
| ViewModel | MovieViewModel.map() |
The hook is a frontend Application Service. It coordinates:
- Authorization (via
useAuth) - Data retrieval (React Query)
- Commands (mutations)
- View-model mapping
- Feature-level state (isSaving, canUpdate)
You can swap React Query for any other data-fetching library, or swap the API layer without touching a single component. The boundary is the hook’s return type.
The Full Version: Adding Form, Validation, and Permissions
Once you bring in TanStack Form, Zod, and CASL, the pattern really shines. The hook grows in reach but stays single-responsibility:
// hooks/useMovie.ts
import { useForm } from '@tanstack/react-form'
import { zodValidator } from '@tanstack/zod-form-adapter'
import { useAbility } from '@casl/react'
import { AbilityContext } from './ability-context'
interface UseMovieOptions {
onSaveSuccess?: (savedId: string) => void
onDeleteSuccess?: () => void
}
export const useMovie = (id?: string, options?: UseMovieOptions) => {
const { userId } = useAuth()
const ability = useAbility(AbilityContext)
// Data
const movieQuery = useQuery({
queryKey: movieKeys.byId(id!),
queryFn: () => movieApi.getById(id!),
enabled: !!id,
select: MovieViewModel.map,
})
// Permissions (CASL + ownership)
const canCreate = ability.can('create', 'Movie')
const canUpdate = ability.can('update', 'Movie') || movieQuery.data?.authorId === userId
const canDelete = ability.can('delete', 'Movie') && !!id
// Mutations
const createMutation = useMutation({
mutationFn: movieApi.create,
onSuccess: (response: CreateMovieResponse) => options?.onSaveSuccess?.(response.id),
})
const updateMutation = useMutation({
mutationFn: (data: MovieFormData) => movieApi.update(id!, data),
onSuccess: () => options?.onSaveSuccess?.(id!),
})
const deleteMutation = useMutation({
mutationFn: () => movieApi.delete(id!),
onSuccess: () => options?.onDeleteSuccess?.(),
})
// Form (TanStack Form + Zod)
const form = useForm({
defaultValues: {
title: movieQuery.data?.title ?? '',
rating: movieQuery.data?.rating ?? 0,
},
validators: { onChange: zodValidator(movieSchema) },
onSubmit: async ({ value }) => {
if (id) {
await updateMutation.mutateAsync(value)
} else {
await createMutation.mutateAsync(value)
}
},
})
return {
movieId: id,
entity: movieQuery.data,
isLoading: movieQuery.isLoading,
movieError: movieQuery.error,
form,
saveMovie: form.handleSubmit,
deleteMovie: deleteMutation.mutate,
canCreate,
canUpdate,
canDelete,
isSaving: createMutation.isPending || updateMutation.isPending,
isDeleting: deleteMutation.isPending,
saveError: createMutation.error || updateMutation.error,
deleteError: deleteMutation.error,
}
}
The component becomes even simpler because form state is fully encapsulated:
function MoviePage({ id }: { id: string }) {
const { entity, form, saveMovie, canUpdate } = useMovie(id)
if (!entity) return <div>Loading...</div>
return (
<form onSubmit={(e) => { e.preventDefault(); saveMovie() }}>
<form.Field name="title">
{(field) => (
<input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
)}
</form.Field>
<button disabled={!canUpdate}>Save</button>
</form>
)
}
The component doesn’t know about CASL, TanStack Form, Zod, or React Query. It just calls useMovie and renders what it gets.
The hook handles create and edit mode through the id parameter. When id is undefined, the form starts empty, canUpdate is false (no entity to update yet), and saveMovie calls the create mutation instead of update. After a successful create, onSaveSuccess fires with the new entity’s ID so the container can navigate to it. This is why the save callback receives savedId: string — the container doesn’t need to figure out whether it was a create or an update.
Why This Scales
Every feature in your app gets a single hook: useMovie, useBooking, useUserProfile, useInvoice. Each hook has the same shape — it exposes data, commands, and permission flags. New team members learn one pattern per feature instead of per library.
The benefits compound:
- Swappable — Replace Axios with tRPC? Change
movieApi.ts. No component changes. - Auditable — All authorization logic for “movies” lives in one function.
- Co-located — Form validation schema lives next to the mutation that submits it.
This doesn’t mean every hook needs TanStack Form and CASL. The pattern scales down too. A read-only page can skip the form and mutations entirely. The point is the boundary itself: one hook per entity, encapsulating everything the UI needs to interact with that entity.
Container, Presenter, and the Testable Contract
Because the hook owns all orchestration, the component tree splits cleanly into two layers: container (calls the hook) and presenter (renders the return value). The return type becomes the explicit contract between them:
// types/movie.ts
export interface UseMovieReturn {
movieId: string | undefined
entity: ViewModelMovie | null
isLoading: boolean
movieError: Error | null
createMovie: (request: CreateMovieRequest) => void
updateMovie: (request: UpdateMovieRequest) => void
deleteMovie: () => void
saveMovie: () => void
canCreate: boolean
canUpdate: boolean
canDelete: boolean
isSaving: boolean
isDeleting: boolean
saveError: Error | null
deleteError: Error | null
}
The container wires the hook and side effects. The entire hook return is passed as a single prop named after the entity — no spreading, no destructuring layers:
// containers/MovieContainer.tsx
function MovieContainer({ id }: { id: string }) {
const movie = useMovie(id, {
onSaveSuccess: (savedId) => navigate(`/movies/${savedId}`),
onDeleteSuccess: () => navigate('/movies'),
})
return <MoviePresenter movie={movie} />
}
The presenter is a pure function of the single movie prop. No hooks, no side effects:
// presenters/MoviePresenter.tsx
function MoviePresenter({ movie }: { movie: UseMovieReturn }) {
if (movie.isLoading) return <div>Loading...</div>
if (!movie.entity) return <div>Movie not found</div>
return (
<div>
<h1>{movie.entity.title}</h1>
<p>Rating: {movie.entity.displayRating}</p>
<button
disabled={!movie.canUpdate || movie.isSaving}
onClick={() => movie.updateMovie({ title: 'New Title' })}
>
Save
</button>
</div>
)
}
This separation makes testing straightforward. UI tests with React Testing Library are hard — every component needs providers, router, query client, and auth context mocked. The presenter changes this entirely. It’s a pure function of UseMovieReturn. No providers, no hooks, no setup:
// presenters/__tests__/MoviePresenter.test.tsx
describe('MoviePresenter', () => {
const baseState: UseMovieReturn = {
movieId: '1',
entity: null,
isLoading: true,
movieError: null,
createMovie: vi.fn(),
updateMovie: vi.fn(),
deleteMovie: vi.fn(),
saveMovie: vi.fn(),
canCreate: false,
canUpdate: false,
canDelete: false,
isSaving: false,
isDeleting: false,
saveError: null,
deleteError: null,
}
it('renders movie details', () => {
render(
<MoviePresenter
movie={{
...baseState,
isLoading: false,
entity: { id: '1', title: 'Inception', displayRating: '8.8/10', isPopular: true, authorId: '42' },
canUpdate: true,
}}
/>
)
expect(screen.getByText('Inception')).toBeInTheDocument()
expect(screen.getByText('8.8/10')).toBeInTheDocument()
})
it('disables save when user cannot update', () => {
render(
<MoviePresenter
movie={{
...baseState,
isLoading: false,
entity: { id: '1', title: 'Inception', displayRating: '8.8/10', isPopular: true, authorId: '42' },
canUpdate: false,
}}
/>
)
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled()
})
})
The hook itself is tested at the orchestration layer — does it return the right shape under different states?
// hooks/__tests__/useMovie.test.ts
describe('useMovie', () => {
it('returns loading state on mount', () => {
const { result } = renderHook(() => useMovie('123'))
expect(result.current.movieId).toBe('123')
expect(result.current.isLoading).toBe(true)
expect(result.current.entity).toBeNull()
expect(typeof result.current.updateMovie).toBe('function')
expect(result.current.canUpdate).toBe(false)
expect(result.current.isSaving).toBe(false)
})
})
No providers wrapped around the test. No mocked router. The return type is the contract. Every feature hook follows the same test pattern because every hook returns the same kind of shape.
We could extract a TestUseMovieReturn helper with named states like .loading() and .editable() — one line per test case.
Start with the simple version. Add form state when you need it. Add permissions when the product demands it. The shape stays the same, and your components never need to learn what’s behind the curtain.