· 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 canUpdate and functions like saveMovie()
  • 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 / UIComponent
Application ServiceuseMovie() hook
RepositorymovieApi.ts
DTOAPI response types
ViewModelMovieViewModel.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.


Index

Index

Back to Blog