Prompt Template 14: Client State Management
Template for implementing state management in interactive Client Components.
1. Context Loading
Section titled “1. Context Loading”Read these files before starting:
docs/dev-frontend-guides/03_COMPONENT_ARCHITECTURE.md (server vs client section)apps/web/src/components/**/*.client.tsx (existing client components){COMPONENT_SPEC_PATH} (the component specification)2. Prompt Template
Section titled “2. Prompt Template”I need to implement client-side state management for the {COMPONENT_NAME} module({MODULE_ALIAS}).
## Component Overview
{COMPONENT_DESCRIPTION}
The server component renders the static shell. The client component hydrateswith interactivity. Only the interactive parts use 'use client'.
## Interaction Requirements
### State to Track
| State Variable | Type | Initial Value | Description ||--------------------|-----------------|-----------------|------------------------------------|| {STATE_VAR_1} | {TYPE} | {INITIAL} | {DESCRIPTION} || {STATE_VAR_2} | {TYPE} | {INITIAL} | {DESCRIPTION} || ... | ... | ... | ... |
### User Actions
| Action | Trigger | State Change | Side Effect ||---------------------|--------------------------|-------------------------------------|-----------------------|| {ACTION_1} | {TRIGGER} (click, key) | {STATE_CHANGE} | {SIDE_EFFECT_OR_NONE} || {ACTION_2} | {TRIGGER} | {STATE_CHANGE} | {SIDE_EFFECT_OR_NONE} || ... | ... | ... | ... |
### Animation Needs
{ANIMATION_DESCRIPTION}
- Transition type: {CSS_TRANSITION / FRAMER_MOTION / CSS_KEYFRAMES}- Duration: {DURATION}- Easing: {EASING}- Reduced motion behaviour: {REDUCED_MOTION_BEHAVIOUR}
### Timer / Interval Management
{TIMER_DESCRIPTION_OR_NONE}
- Autoplay interval: {INTERVAL_MS}- Pause conditions: {PAUSE_CONDITIONS} (hover, focus, tab hidden, reduced motion)- Resume conditions: {RESUME_CONDITIONS}
### Event Listeners
| Event | Target | Handler Description | Cleanup Required? ||-------------------|-----------------|--------------------------------------|--------------------|| {EVENT_1} | {TARGET} | {HANDLER_DESC} | Yes/No || {EVENT_2} | {TARGET} | {HANDLER_DESC} | Yes/No || ... | ... | ... | ... |
### Keyboard Support
| Key(s) | Action ||-------------------|-------------------------------------------------------------|| {KEY_1} | {ACTION} || {KEY_2} | {ACTION} || ... | ... |
## State Management Pattern
Use: {useState / useReducer / compound pattern}
Justification: {WHY_THIS_PATTERN}
## Requirements
- State logic lives in the `.client.tsx` file with 'use client' directive- Server component renders the full static HTML shell (works without JS)- Client component hydrates to add interactivity- All event listeners cleaned up in useEffect return functions- No memory leaks: intervals and timers cleared on unmount- Keyboard shortcuts documented and functional- Reduced motion: honour @media (prefers-reduced-motion: reduce)- Both touch and mouse events handled- No `typeof window` checks needed at render time (useEffect handles it)3. Acceptance Criteria
Section titled “3. Acceptance Criteria”- Interactive logic isolated in
{ComponentName}.client.tsxwith'use client'directive - Server component renders complete static HTML (meaningful content without JS)
- Client component hydrates without layout shift
-
useStateused for simple toggles / single values -
useReducerused if 3+ related state variables or complex transitions -
useCallbackwraps event handlers passed to child components -
useRefused for DOM measurements, not for storing render-affecting state - All
useEffecthooks have proper cleanup (return function) - Intervals and timers cleared on unmount
- Scroll, resize, keyboard listeners removed on unmount
-
prefers-reduced-motionrespected: animations disabled or simplified - Touch events (touchstart, touchmove, touchend) handled alongside mouse events
- Keyboard navigation works: Tab, Enter, Space, Arrow keys as appropriate
- Focus management: focus visible, focus trapped in modals if applicable
- No derived state stored in useState (compute inline or useMemo)
- No unstable object/array references causing unnecessary re-renders
4. Common Pitfalls
Section titled “4. Common Pitfalls”-
Putting the entire module in
'use client'. Only the interactive parts should be Client Components. The outer module, static text, and images should remain in a Server Component. -
Not cleaning up intervals and listeners. Every
setInterval,setTimeout,addEventListenerin auseEffectmust have a corresponding cleanup in the return function. Missing cleanup causes memory leaks and ghost behaviour. -
Not handling SSR context. Code that accesses
window,document, ornavigatormust run insideuseEffector behind a ref check, never at module scope or during render. -
Ignoring reduced motion. Users with
prefers-reduced-motion: reduceshould get no autoplay, no sliding transitions, and instant state changes. Use theuseReducedMotion()hook or CSS media query. -
Storing derived values in state. If a value can be computed from existing state or props, compute it inline. Do not create a separate
useStatefor it. Example:isLastSlideshould beactiveIndex === items.length - 1, not a separate state variable. -
Unstable references causing re-renders. Objects and arrays created inline in render are new references every render. Wrap in
useMemooruseCallbackif passed as props to memoized children. -
Missing keyboard support. Interactive components must be operable via keyboard. Carousels need arrow keys, modals need Escape, tabs need arrow keys + Home/End.
-
Not pausing autoplay on tab visibility change. Use
document.visibilitychangeto pause intervals when the tab is hidden. Users should not return to a carousel that has advanced 30 slides.
5. Example
Section titled “5. Example”M05 Hero Slider
Section titled “M05 Hero Slider”Component description: Full-viewport image slider on the homepage. Shows one slide at a time with crossfade transition. Autoplay with configurable interval.
File structure:
packages/ui/src/modules/M05HeroSlider/ M05HeroSlider.tsx -- Server Component (static shell) M05HeroSlider.client.tsx -- Client Component (interactivity) M05HeroSlider.module.scss -- Styles M05HeroSlider.stories.tsx -- Storybook storiesState (useReducer because 4+ related variables):
'use client';
import { useReducer, useEffect, useCallback, useRef } from 'react';
interface SliderState { activeIndex: number; isPlaying: boolean; direction: 'next' | 'prev'; isTransitioning: boolean;}
type SliderAction = | { type: 'NEXT' } | { type: 'PREV' } | { type: 'GO_TO'; index: number } | { type: 'PLAY' } | { type: 'PAUSE' } | { type: 'TRANSITION_END' };
function sliderReducer(state: SliderState, action: SliderAction): SliderState { switch (action.type) { case 'NEXT': return { ...state, activeIndex: (state.activeIndex + 1) % totalSlides, direction: 'next', isTransitioning: true, }; case 'PREV': return { ...state, activeIndex: (state.activeIndex - 1 + totalSlides) % totalSlides, direction: 'prev', isTransitioning: true, }; case 'GO_TO': return { ...state, activeIndex: action.index, direction: action.index > state.activeIndex ? 'next' : 'prev', isTransitioning: true, }; case 'PLAY': return { ...state, isPlaying: true }; case 'PAUSE': return { ...state, isPlaying: false }; case 'TRANSITION_END': return { ...state, isTransitioning: false }; default: return state; }}Autoplay with pause on hover/focus/hidden tab/reduced motion:
interface HeroSliderClientProps { slides: SlideData[]; autoplayInterval?: number; children: React.ReactNode;}
export function HeroSliderClient({ slides, autoplayInterval = 6000, children,}: HeroSliderClientProps) { const totalSlides = slides.length; const [state, dispatch] = useReducer(sliderReducer, { activeIndex: 0, isPlaying: true, direction: 'next', isTransitioning: false, });
const containerRef = useRef<HTMLDivElement>(null); const reducedMotion = useRef(false);
// Check reduced motion preference useEffect(() => { const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); reducedMotion.current = mql.matches; if (mql.matches) dispatch({ type: 'PAUSE' });
const handler = (e: MediaQueryListEvent) => { reducedMotion.current = e.matches; dispatch({ type: e.matches ? 'PAUSE' : 'PLAY' }); }; mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); }, []);
// Autoplay interval useEffect(() => { if (!state.isPlaying || reducedMotion.current) return;
const id = setInterval(() => { dispatch({ type: 'NEXT' }); }, autoplayInterval);
return () => clearInterval(id); }, [state.isPlaying, autoplayInterval]);
// Pause on tab hidden useEffect(() => { const handler = () => { if (document.hidden) { dispatch({ type: 'PAUSE' }); } else if (!reducedMotion.current) { dispatch({ type: 'PLAY' }); } }; document.addEventListener('visibilitychange', handler); return () => document.removeEventListener('visibilitychange', handler); }, []);
// Keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowLeft': dispatch({ type: 'PREV' }); dispatch({ type: 'PAUSE' }); break; case 'ArrowRight': dispatch({ type: 'NEXT' }); dispatch({ type: 'PAUSE' }); break; } }, []);
// Touch / swipe gestures const touchStart = useRef<number>(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => { touchStart.current = e.touches[0].clientX; dispatch({ type: 'PAUSE' }); }, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => { const delta = e.changedTouches[0].clientX - touchStart.current; if (Math.abs(delta) > 50) { dispatch({ type: delta > 0 ? 'PREV' : 'NEXT' }); } }, []);
// Pause on hover / focus const handleMouseEnter = useCallback(() => dispatch({ type: 'PAUSE' }), []); const handleMouseLeave = useCallback(() => { if (!reducedMotion.current) dispatch({ type: 'PLAY' }); }, []);
return ( <div ref={containerRef} role="region" aria-roledescription="carousel" aria-label="Hero slider" onKeyDown={handleKeyDown} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onFocus={handleMouseEnter} onBlur={handleMouseLeave} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} tabIndex={0} > {/* Slides rendered with activeIndex, transition classes applied via state */} {/* Dot navigation and arrow buttons */} {/* children = server-rendered static content */} </div> );}Key decisions in this example:
useReducerchosen because 4 related state variables with interdependent transitions- Autoplay pauses on hover, focus, tab hidden, and reduced motion
- Swipe threshold of 50px avoids accidental swipes
- Keyboard arrows pause autoplay (user took control)
aria-roledescription="carousel"for screen readers- Reduced motion check uses both initial query and change listener
- All listeners cleaned up in useEffect return functions