Context is all you need
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?
- Every intermediate component must accept and forward props
- Adding a new derived value means updating every component signature
- Derived values (isOwner, isAdmin) recomputed or passed everywhere
- 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.
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 API | Form state |
| Computed/derived values | Change tracking |
| Permissions (isOwner, etc) | Validation |
| Used on ALL profile pages | Save/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.
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.

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
onSubmithandler decides whether to callcreateProfileorupdateProfile - Change detection adapts automatically based on whether
initialDatawas 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.
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
| Benefit | How |
|---|---|
| No prop threading | Components grab what they need directly |
| Derived values computed once | Provider calculates, all consumers share |
| Easy refactoring | Add values to provider, use anywhere |
| Consistent data | Single source of truth for all components |
Read-only vs upsert separation
| Benefit | How |
|---|---|
| One form for create and edit | Same UpsertContext, different initial data |
| Clear boundaries | Reading vs writing clearly separated into two contexts |
| Flexibility | Components 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.