Skip to content

Prompt Template 14: Client State Management

Template for implementing state management in interactive Client Components.


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)

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 hydrates
with 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)

  • Interactive logic isolated in {ComponentName}.client.tsx with 'use client' directive
  • Server component renders complete static HTML (meaningful content without JS)
  • Client component hydrates without layout shift
  • useState used for simple toggles / single values
  • useReducer used if 3+ related state variables or complex transitions
  • useCallback wraps event handlers passed to child components
  • useRef used for DOM measurements, not for storing render-affecting state
  • All useEffect hooks have proper cleanup (return function)
  • Intervals and timers cleared on unmount
  • Scroll, resize, keyboard listeners removed on unmount
  • prefers-reduced-motion respected: 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

  1. 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.

  2. Not cleaning up intervals and listeners. Every setInterval, setTimeout, addEventListener in a useEffect must have a corresponding cleanup in the return function. Missing cleanup causes memory leaks and ghost behaviour.

  3. Not handling SSR context. Code that accesses window, document, or navigator must run inside useEffect or behind a ref check, never at module scope or during render.

  4. Ignoring reduced motion. Users with prefers-reduced-motion: reduce should get no autoplay, no sliding transitions, and instant state changes. Use the useReducedMotion() hook or CSS media query.

  5. Storing derived values in state. If a value can be computed from existing state or props, compute it inline. Do not create a separate useState for it. Example: isLastSlide should be activeIndex === items.length - 1, not a separate state variable.

  6. Unstable references causing re-renders. Objects and arrays created inline in render are new references every render. Wrap in useMemo or useCallback if passed as props to memoized children.

  7. Missing keyboard support. Interactive components must be operable via keyboard. Carousels need arrow keys, modals need Escape, tabs need arrow keys + Home/End.

  8. Not pausing autoplay on tab visibility change. Use document.visibilitychange to pause intervals when the tab is hidden. Users should not return to a carousel that has advanced 30 slides.


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 stories

State (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:

  • useReducer chosen 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