Log 04

Context is all you need

See on GitHub

Why use context at all?

Most developers default to prop drilling, aka passing data through component props at every level. This works for simple cases, but breaks down quickly.

The prop drilling problem

// Prop drilling: every component in the chain needs the props
function ProfilePage({ profile, currentUserId }) {
  const isOwner = profile.id === currentUserId
  const isAdmin = profile.role === "admin"
  
  return (
    <ProfileLayout profile={profile} isOwner={isOwner} isAdmin={isAdmin}>
      <ProfileHeader profile={profile} isOwner={isOwner} isAdmin={isAdmin} />
      <ProfileContent profile={profile} isOwner={isOwner}>
        <ProfileBio profile={profile} />
        <ProfileActions isOwner={isOwner} isAdmin={isAdmin} profileId={profile.id} />
      </ProfileContent>
    </ProfileLayout>
  )
}

What's wrong with this?

  1. Every intermediate component must accept and forward props
  2. Adding a new derived value means updating every component signature
  3. Derived values (isOwner, isAdmin) recomputed or passed everywhere
  4. Refactoring is painful - change one prop, update 10 files

Aight, now let's use context

Toggle between different user roles to see how context-derived values (isOwner, isAdmin) change what's rendered. The context computes these values once, and all child components access them directly.

J

Jane Cooper

jane@example.com

Member since Mar 2023

Bio

Senior Developer at Acme Corp. Passionate about React, TypeScript, and clean architecture patterns.

'use client'

import * as React from 'react'
import { cn } from '@/lib/cn'
import { ProfileProvider, useProfileContext } from './profile-context'
import type { CurrentUser, UserProfile } from './types'
import { EyeIcon } from 'lucide-react'

// --- UI Components (using context) ---
function ProfileHeader() {
  const { displayName, isAdmin, memberSince, profile } = useProfileContext()

  return (
    <div className="flex items-start gap-4">
      <div className="bg-muted flex size-16 shrink-0 items-center justify-center rounded-full text-2xl font-medium">
        {displayName.charAt(0).toUpperCase()}
      </div>
      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2">
          <h2 className="truncate text-lg font-semibold">{displayName}</h2>
          {isAdmin && (
            <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs font-medium">
              Admin View
            </span>
          )}
        </div>
        <p className="text-muted-foreground text-sm">{profile.email}</p>
        <p className="text-muted-foreground text-xs">
          Member since {memberSince}
        </p>
      </div>
    </div>
  )
}

function ProfileBio() {
  const { profile } = useProfileContext()

  return (
    <div className="space-y-1.5">
      <h3 className="text-sm font-medium">Bio</h3>
      <p className="text-muted-foreground text-sm">{profile.bio}</p>
    </div>
  )
}

function ProfileActions() {
  const { isOwner, isAdmin } = useProfileContext()

  return (
    <div className="flex gap-2">
      <button className="bg-muted hover:bg-muted/80 rounded-md border border-transparent px-3 py-1.5 text-sm transition-colors">
        Share Profile
      </button>
      {isOwner && (
        <button className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md border border-transparent px-3 py-1.5 text-sm transition-colors">
          Edit Profile
        </button>
      )}
      {isAdmin && !isOwner && (
        <button className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-500 transition-colors hover:bg-red-500/20">
          Ban User
        </button>
      )}
    </div>
  )
}

// --- Demo ---
const mockProfile: UserProfile = {
  id: 'user-1',
  name: 'Jane Cooper',
  email: 'jane@example.com',
  bio: 'Senior Developer at Acme Corp. Passionate about React, TypeScript, and clean architecture patterns.',
  avatarUrl: '',
  role: 'editor',
  createdAt: '2023-03-15T00:00:00.000Z',
}

export function ProfileContextDemo() {
  const [viewAs, setViewAs] = React.useState<'owner' | 'admin' | 'visitor'>(
    'owner'
  )

  const currentUser: CurrentUser =
    viewAs === 'owner'
      ? { id: mockProfile.id, role: mockProfile.role }
      : viewAs === 'admin'
        ? { id: 'admin-user', role: 'admin' }
        : { id: 'visitor-user', role: 'viewer' }

  return (
    <div className="mx-auto w-full max-w-md space-y-4 p-6">
      {/* View switcher */}
      <div className="space-y-2">
        <div className="bg-muted/50 inline-flex rounded-lg p-1">
          {(['owner', 'admin', 'visitor'] as const).map((role) => (
            <button
              key={role}
              onClick={() => setViewAs(role)}
              className={cn(
                'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium capitalize transition-colors',
                viewAs === role
                  ? 'bg-background shadow-sm'
                  : 'text-muted-foreground hover:text-foreground'
              )}
            >
              {viewAs === role && <EyeIcon className="size-4" />}
              {role}
            </button>
          ))}
        </div>
      </div>

      {/* Profile card */}
      <ProfileProvider profile={mockProfile} currentUser={currentUser}>
        <div className="bg-card border-border space-y-4 rounded-xl border p-4">
          <ProfileHeader />
          <ProfileBio />
          <ProfileActions />
        </div>
      </ProfileProvider>
    </div>
  )
}

export default ProfileContextDemo

Now, what if we want to edit the profile?

The ProfileContext above works great for viewing. But what happens when the user wants to update their profile?

We need:

  • Form state (tracking what the user is typing)
  • Change detection (has anything changed?)
  • Validation (is the input valid?)
  • Save/Reset actions

Should we add all this to ProfileContext? No. That would bloat our read-only context with concerns that only matter during editing.

Instead, we create a separate context for mutations:

ProfileContext (Read-Only)EditProfileContext (Form + Mutations)
Data from APIForm state
Computed/derived valuesChange tracking
Permissions (isOwner, etc)Validation
Used on ALL profile pagesSave/Reset/Delete

This separation keeps each context focused on its responsibility.

Edit the form fields to see change tracking, validation, and the save/reset flow in action. Notice how form state, validation errors, and actions are all managed through context. This same context works for creating new profiles too, the only difference is the initial data.

J

Edit Profile

Update your profile information

30/200

'use client'

import { useState } from 'react'
import { cn } from '@/lib/cn'
import {
  EditProfileProvider,
  useEditProfileContext,
} from './edit-profile-context'
import type { ProfileFormState } from './types'

// --- UI Components ---
function EditProfileForm() {
  const { formState, errors, onChange } = useEditProfileContext()

  return (
    <div className="space-y-4">
      <div className="space-y-1.5">
        <label htmlFor="name" className="text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          type="text"
          value={formState.name}
          onChange={(e) => onChange('name', e.target.value)}
          className={cn(
            'border-border bg-background w-full rounded-md border px-3 py-2 text-sm outline-none',
            'focus:ring-ring focus:ring-2',
            errors.name && 'border-red-500 focus:ring-red-500/30'
          )}
          placeholder="Your name"
        />
        {errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
      </div>

      <div className="space-y-1.5">
        <label htmlFor="bio" className="text-sm font-medium">
          Bio
        </label>
        <textarea
          id="bio"
          value={formState.bio}
          onChange={(e) => onChange('bio', e.target.value)}
          rows={3}
          className={cn(
            'border-border bg-background w-full resize-none rounded-md border px-3 py-2 text-sm outline-none',
            'focus:ring-ring focus:ring-2',
            errors.bio && 'border-red-500 focus:ring-red-500/30'
          )}
          placeholder="Tell us about yourself"
        />
        <div className="flex items-center justify-between">
          {errors.bio ? (
            <p className="text-xs text-red-500">{errors.bio}</p>
          ) : (
            <span />
          )}
          <p
            className={cn(
              'text-muted-foreground text-xs',
              formState.bio.length > 200 && 'text-red-500'
            )}
          >
            {formState.bio.length}/200
          </p>
        </div>
      </div>
    </div>
  )
}

function EditProfileActions() {
  const { hasChanges, isSaving, onSave, onReset } = useEditProfileContext()

  return (
    <div className="flex items-center justify-end gap-2">
      <button
        onClick={onReset}
        disabled={!hasChanges || isSaving}
        className={cn(
          'text-muted-foreground bg-muted rounded-md px-4 py-2 text-sm transition-colors',
          hasChanges && !isSaving
            ? 'hover:text-foreground cursor-pointer'
            : 'cursor-not-allowed opacity-50'
        )}
      >
        Reset
      </button>
      <button
        onClick={onSave}
        disabled={!hasChanges || isSaving}
        className={cn(
          'bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium transition-colors',
          hasChanges && !isSaving
            ? 'hover:bg-primary/90'
            : 'cursor-not-allowed opacity-50'
        )}
      >
        {isSaving ? 'Saving...' : 'Save Changes'}
      </button>
    </div>
  )
}

// --- Demo ---
const initialProfile: ProfileFormState = {
  name: 'Jane Cooper',
  bio: 'Senior Developer at Acme Corp.',
}

export function EditProfileContextDemo() {
  const [savedData, setSavedData] = useState(initialProfile)
  const [showSuccess, setShowSuccess] = useState(false)

  const handleSubmit = async (data: ProfileFormState) => {
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 1000))
    setSavedData(data)
    setShowSuccess(true)
    setTimeout(() => setShowSuccess(false), 2000)
  }

  return (
    <div className="relative mx-auto w-full max-w-md space-y-4 p-6">
      {/* Success toast */}
      {showSuccess && (
        <div className="animate-in slide-in-from-top-2 absolute left-1/2 top-4 z-50 -translate-x-1/2 rounded-lg border border-green-500/30 backdrop-blur-lg bg-green-500/20 px-4 py-2">
          <p className="text-sm font-medium text-green-600">Profile saved!</p>
        </div>
      )}

      <EditProfileProvider initialData={savedData} onSubmit={handleSubmit}>
        <div className="bg-card border-border space-y-4 rounded-xl border p-4">
          <div className="flex items-center gap-3">
            <div className="bg-muted flex size-12 items-center justify-center rounded-full text-lg font-medium">
              {savedData.name.charAt(0).toUpperCase()}
            </div>
            <div>
              <h2 className="font-semibold">Edit Profile</h2>
              <p className="text-muted-foreground text-sm">
                Update your profile information
              </p>
            </div>
          </div>

          <EditProfileForm />
          <EditProfileActions />
        </div>
      </EditProfileProvider>
    </div>
  )
}

export default EditProfileContextDemo

The upsert insight: edit and create are the same

Cool, now we can edit profiles, but we won't have any profile to edit if we don't create one first.

Real quote from Descartes
Real quote from Descartes

Here's an insight that simplifies everything: editing and creating are the same operation. The only difference is whether you start with data or not.

// Edit = Upsert with initialData
<UpsertProfileProvider initialData={existingProfile}>
  <ProfileForm />
</UpsertProfileProvider>
 
// Create = Upsert with empty/default initialData
<UpsertProfileProvider initialData={{ name: '', bio: '' }}>
  <ProfileForm />
</UpsertProfileProvider>

This means EditProfileProvider is really just UpsertProfileProvider in disguise.

Why this matters:

  • One context, one form, one set of components for both create and edit
  • The onSubmit handler decides whether to call createProfile or updateProfile
  • Change detection adapts automatically based on whether initialData was provided
  • Less code to maintain, fewer bugs to fix

Toggle between "Create New" and "Edit Existing" to see the same form and context handle both operations. Notice how the button labels, change detection, and reset behavior adapt automatically.

J

Edit Profile

Update your profile information

30/200

'use client'

import { useState } from 'react'
import { cn } from '@/lib/cn'
import {
  UpsertProfileProvider,
  useUpsertProfileContext,
} from './upsert-profile-context'
import type { ProfileFormState } from './types'

// --- UI Components ---
function ProfileForm() {
  const { formState, errors, onChange } = useUpsertProfileContext()

  return (
    <div className="space-y-4">
      <div className="space-y-1.5">
        <label htmlFor="name" className="text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          type="text"
          value={formState.name}
          onChange={(e) => onChange('name', e.target.value)}
          className={cn(
            'border-border bg-background w-full rounded-md border px-3 py-2 text-sm outline-none',
            'focus:ring-ring focus:ring-2',
            errors.name && 'border-red-500 focus:ring-red-500/30'
          )}
          placeholder="Your name"
        />
        {errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
      </div>

      <div className="space-y-1.5">
        <label htmlFor="bio" className="text-sm font-medium">
          Bio
        </label>
        <textarea
          id="bio"
          value={formState.bio}
          onChange={(e) => onChange('bio', e.target.value)}
          rows={3}
          className={cn(
            'border-border bg-background w-full resize-none rounded-md border px-3 py-2 text-sm outline-none',
            'focus:ring-ring focus:ring-2',
            errors.bio && 'border-red-500 focus:ring-red-500/30'
          )}
          placeholder="Tell us about yourself"
        />
        <div className="flex items-center justify-between">
          {errors.bio ? (
            <p className="text-xs text-red-500">{errors.bio}</p>
          ) : (
            <span />
          )}
          <p
            className={cn(
              'text-muted-foreground text-xs',
              formState.bio.length > 200 && 'text-red-500'
            )}
          >
            {formState.bio.length}/200
          </p>
        </div>
      </div>
    </div>
  )
}

function ProfileActions() {
  const { isCreateMode, hasChanges, isSaving, onSave, onReset } =
    useUpsertProfileContext()

  return (
    <div className="flex items-center justify-end">
      <div className="flex items-center gap-2">
        <button
          onClick={onReset}
          disabled={!hasChanges || isSaving}
          className={cn(
            'text-muted-foreground bg-muted rounded-md px-4 py-2 text-sm transition-colors',
            hasChanges && !isSaving
              ? 'hover:text-foreground cursor-pointer'
              : 'cursor-not-allowed opacity-50'
          )}
        >
          {isCreateMode ? 'Clear' : 'Reset'}
        </button>
        <button
          onClick={onSave}
          disabled={!hasChanges || isSaving}
          className={cn(
            'bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium transition-colors',
            hasChanges && !isSaving
              ? 'hover:bg-primary/90'
              : 'cursor-not-allowed opacity-50'
          )}
        >
          {isSaving
            ? isCreateMode
              ? 'Creating...'
              : 'Saving...'
            : isCreateMode
              ? 'Create Profile'
              : 'Save Changes'}
        </button>
      </div>
    </div>
  )
}

// --- Demo ---
type Mode = 'create' | 'edit'

const existingProfile: ProfileFormState = {
  name: 'Jane Cooper',
  bio: 'Senior Developer at Acme Corp.',
}

export function UpsertProfileContextDemo() {
  const [mode, setMode] = useState<Mode>('edit')
  const [profiles, setProfiles] = useState<ProfileFormState[]>([
    existingProfile,
  ])
  const [showSuccess, setShowSuccess] = useState(false)
  const [successMessage, setSuccessMessage] = useState('')

  // Key to force re-mount when switching modes
  const [formKey, setFormKey] = useState(0)

  const handleModeChange = (newMode: Mode) => {
    setMode(newMode)
    setFormKey((k) => k + 1) // Reset form state
  }

  const handleSubmit = async (data: ProfileFormState) => {
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 1000))

    if (mode === 'create') {
      setProfiles((prev) => [...prev, data])
      setSuccessMessage('Profile created!')
    } else {
      setProfiles((prev) => prev.map((p, i) => (i === 0 ? data : p)))
      setSuccessMessage('Profile saved!')
    }

    setShowSuccess(true)
    setTimeout(() => setShowSuccess(false), 2000)

    // After creating, switch to edit mode with the new profile
    if (mode === 'create') {
      setMode('edit')
      setFormKey((k) => k + 1)
    }
  }

  // Get initial data based on mode
  const initialData = mode === 'edit' ? profiles[0] : undefined

  return (
    <div className="relative mx-auto w-full max-w-md space-y-4 p-6">
      {/* Success toast */}
      {showSuccess && (
        <div className="animate-in slide-in-from-top-2 absolute top-4 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-green-500/30 bg-green-500/20 px-4 py-2 backdrop-blur-lg">
          <p className="text-sm font-medium text-green-600">{successMessage}</p>
        </div>
      )}

      {/* Mode Toggle */}
      <div className="bg-muted flex rounded-lg p-1">
        <button
          onClick={() => handleModeChange('create')}
          className={cn(
            'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors',
            mode === 'create'
              ? 'bg-background text-foreground shadow-sm'
              : 'text-muted-foreground hover:text-foreground'
          )}
        >
          Create New
        </button>
        <button
          onClick={() => handleModeChange('edit')}
          className={cn(
            'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors',
            mode === 'edit'
              ? 'bg-background text-foreground shadow-sm'
              : 'text-muted-foreground hover:text-foreground'
          )}
        >
          Edit Existing
        </button>
      </div>

      {/* Same form, different initial data */}
      <UpsertProfileProvider
        key={formKey}
        initialData={initialData}
        onSubmit={handleSubmit}
      >
        <div className="bg-card border-border space-y-4 rounded-xl border p-4">
          <div className="flex items-center gap-3">
            <div className="bg-muted flex size-12 items-center justify-center rounded-full text-lg font-medium">
              {mode === 'edit' ? (
                profiles[0].name.charAt(0).toUpperCase()
              ) : (
                <span className="text-muted-foreground">?</span>
              )}
            </div>
            <div>
              <h2 className="font-semibold">
                {mode === 'create' ? 'Create Profile' : 'Edit Profile'}
              </h2>
              <p className="text-muted-foreground text-sm">
                {mode === 'create'
                  ? 'Fill in your profile information'
                  : 'Update your profile information'}
              </p>
            </div>
          </div>

          <ProfileForm />
          <ProfileActions />
        </div>
      </UpsertProfileProvider>
    </div>
  )
}

export default UpsertProfileContextDemo

The pattern in 4 steps

Step 1: Define your ViewContext (read-only data + derived values)

type ProfileContextType = {
  profile: Profile
  isOwner: boolean
  isAdmin: boolean
}
 
function ProfileProvider({ profile, currentUserId, children }) {
  const isOwner = profile.id === currentUserId
  const isAdmin = profile.role === 'admin'
  
  return (
    <ProfileContext.Provider value={{ profile, isOwner, isAdmin }}>
      {children}
    </ProfileContext.Provider>
  )
}

Step 2: Define your UpsertContext (form state + actions)

type UpsertProfileContextType = {
  formState: ProfileFormState
  isCreateMode: boolean
  hasChanges: boolean
  isSaving: boolean
  errors: Record<string, string>
  onChange: (field: string, value: string) => void
  onSave: () => void
  onReset: () => void
}
 
function UpsertProfileProvider({ initialData, onSubmit, children }) {
  const isCreateMode = !initialData
  const startingData = { ...defaults, ...initialData }
  
  // hasChanges adapts to mode
  const hasChanges = isCreateMode
    ? formState.name !== '' || formState.bio !== ''
    : JSON.stringify(formState) !== JSON.stringify(startingData)
  
  // ... form state, validation, save/reset handlers
}

Step 3: Use ViewContext for viewing pages

<ProfileProvider profile={profile} currentUserId={currentUserId}>
  <ProfileHeader />
  <ProfileBio />
  <ProfileActions />
</ProfileProvider>

Step 4: Use UpsertContext for create or edit

// Create: UpsertProvider with no initialData
<UpsertProfileProvider onSubmit={handleCreate}>
  <ProfileForm />
  <ProfileActions />
</UpsertProfileProvider>
 
// Edit: ViewProvider + UpsertProvider with initialData
<ProfileProvider profile={profile} currentUserId={currentUserId}>
  <UpsertProfileProvider 
    initialData={{ name: profile.name, bio: profile.bio }}
    onSubmit={handleUpdate}
  >
    <ProfileForm />
    <ProfileActions />
  </UpsertProfileProvider>
</ProfileProvider>

Context vs prop drilling

BenefitHow
No prop threadingComponents grab what they need directly
Derived values computed onceProvider calculates, all consumers share
Easy refactoringAdd values to provider, use anywhere
Consistent dataSingle source of truth for all components

Read-only vs upsert separation

BenefitHow
One form for create and editSame UpsertContext, different initial data
Clear boundariesReading vs writing clearly separated into two contexts
FlexibilityComponents work in both create and edit modes automatically

When to use this pattern

Use it when:

  • You have shared data/permissions needed by multiple components
  • You want to avoid prop drilling through intermediate components
  • You need computed/derived values used by multiple components
  • You want clear separation between viewing and editing concerns
  • You have both create and edit flows that share the same form structure

Skip it when:

  • Very shallow component tree (2-3 levels)
  • Data only used by a single component
  • Simple CRUD with no viewing page (just list → edit)

This approach aligns with our Context Over Prop Drilling PR guideline.

Related Logs