Create functional, compositional React components using headless UI patterns with decoupled state management. Use when building React components that need: (1) Composition over complex prop APIs, (2) Separation of logic from presentation (headless patterns), (3) Pure, idempotent rendering, (4) Compound component APIs, (5) Integration with react-query for server state, or (6) Support for both controlled and uncontrolled patterns. Ideal for building flexible, reusable component libraries and data-driven UIs.
/plugin marketplace add nathan-gage/ngage-marketplace/plugin install nathan-gage-react-plugins-react@nathan-gage/ngage-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
assets/select-component-template.tsxreferences/compositional-patterns.mdreferences/react-query-patterns.mdBuild functional, compositional React components following headless UI patterns with proper state separation and react-query integration.
When creating compositional components:
UI State (component-local):
useState, useReducer, contextServer State (react-query):
useQuery, useMutation, useInfiniteQueryKeep these concerns strictly separated.
Compound Components - When sub-components work together:
<Tabs>
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a">Content A</Tabs.Panel>
</Tabs>
Render Props - When consumers need full control:
<DataList data={users}>
{(user) => <UserCard user={user} />}
</DataList>
Headless Hook - When logic is reusable without UI:
const { isOpen, toggle } = useDisclosure();
Create Context for Compound Components:
const Context = createContext<ContextValue | null>(null);
function useComponentContext() {
const ctx = useContext(Context);
if (!ctx) throw new Error('Must use within Parent');
return ctx;
}
Extract Headless Logic:
function useComponentLogic(props) {
// All stateful logic here
return { state, actions };
}
function Component(props) {
const logic = useComponentLogic(props);
return <UI {...logic} />;
}
Support Controlled/Uncontrolled:
function Component({ value, defaultValue, onChange }) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : internal;
const handleChange = (newValue) => {
if (!isControlled) setInternal(newValue);
onChange?.(newValue);
};
return <input value={current} onChange={handleChange} />;
}
Create custom hooks that encapsulate queries:
export function useUserList() {
return useQuery({
queryKey: ['users', 'list'],
queryFn: fetchUsers,
});
}
Organize keys consistently:
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
};
Separate data fetching from presentation:
function UserList() {
const { data, isLoading, error } = useUserList();
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return (
<List>
{data.map(user => <UserItem key={user.id} user={user} />)}
</List>
);
}
Use immutable patterns for optimistic updates:
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (updated) => {
await queryClient.cancelQueries({ queryKey: userKeys.detail(updated.id) });
const previous = queryClient.getQueryData(userKeys.detail(updated.id));
queryClient.setQueryData(userKeys.detail(updated.id), updated);
return { previous };
},
onError: (err, updated, context) => {
queryClient.setQueryData(userKeys.detail(updated.id), context?.previous);
},
onSettled: (data) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(data.id) });
},
});
Same props must always produce same output:
// Pure - always renders same output for same input
function UserCard({ user }: { user: User }) {
return <div>{user.name}</div>;
}
// Impure - side effects in render
function ImpureCard({ user }: { user: User }) {
trackEvent('card_viewed'); // DON'T DO THIS
return <div>{user.name}</div>;
}
// Fixed - side effects in useEffect
function PureCard({ user }: { user: User }) {
useEffect(() => {
trackEvent('card_viewed');
}, [user.id]);
return <div>{user.name}</div>;
}
Never mutate props or state directly:
// Bad - mutates prop
function BadList({ items }: { items: Item[] }) {
items.sort(); // Mutates!
return <ul>{items.map(item => <li>{item.name}</li>)}</ul>;
}
// Good - creates new array
function GoodList({ items }: { items: Item[] }) {
const sorted = [...items].sort();
return <ul>{sorted.map(item => <li>{item.name}</li>)}</ul>;
}
Use clear, specific types:
interface SelectProps {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
children: React.ReactNode;
disabled?: boolean;
}
Always type context values:
interface ContextValue {
state: State;
actions: Actions;
}
const Context = createContext<ContextValue | null>(null);
Support generic types when needed:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
For comprehensive patterns and examples:
references/compositional-patterns.md for headless UI patterns, compound components, render props, slots, and purity guidelinesreferences/react-query-patterns.md for query organization, mutations, infinite queries, and error handlingassets/select-component-template.tsx for a fully-implemented compositional select component demonstrating all patternsfunction UserList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['users'],
queryFn: ({ pageParam = 0 }) => fetchUsers(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
return (
<>
{data?.pages.flatMap(page => page.users).map(user => (
<UserCard key={user.id} user={user} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load More
</button>
)}
</>
);
}
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId),
enabled: !!user, // Only fetch when user exists
});
if (!user) return <Spinner />;
return (
<div>
<h1>{user.name}</h1>
{posts?.map(post => <PostCard key={post.id} post={post} />)}
</div>
);
}
Always wrap components with error boundaries:
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<LoadingSpinner />}>
<ComponentThatMightFail />
</Suspense>
</ErrorBoundary>
Before considering a component complete: