Mastering Composable State in Component UIs
Composable state functions transformed how we write components: instead of classes and lifecycle methods, small functions declare the state and effects a component needs. The primitives are simple; the craft is in composing them.
The core primitives
1. Local state
The most basic primitive adds a piece of state and a setter to a function component:
const [count, setCount] = useLocalState(0);
Every call is independent, so a component can hold several small pieces of state instead of one monolithic object.
2. Effects
Effects run after render and declare their own dependencies:
useEffectOnce(() => {
document.title = `Count: ${count}`;
}, [count]);
The dependency array is a contract: everything the effect reads must be listed, or it will run against stale values.
3. Shared context
Context passes data down the tree without threading props through every level:
const theme = useSharedContext(ThemeContext);
Reach for it when a value is genuinely ambient — theme, locale, session — not as a general store.
Building your own compositions
The real power arrives when you extract component logic into reusable functions:
function useWindowSize() {
const [size, setSize] = useLocalState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffectOnce(() => {
const onResize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return size;
}
The composition owns its cleanup, its subscription, and its state — the component just consumes a value.
Rules that keep you sane
- Only call state functions at the top level — never inside conditions or loops.
- Only call them from components or other compositions.
- Split unrelated concerns into separate effects.
- Always return a cleanup from effects that subscribe to anything.
- Treat the dependency array as part of the code review.
Common use cases
Form handling
function useForm(initialValues) {
const [values, setValues] = useLocalState(initialValues);
const handleChange = (event) => {
const { name, value } = event.target;
setValues((prev) => ({ ...prev, [name]: value }));
};
return { values, handleChange };
}
Data fetching
function useFetch(url) {
const [data, setData] = useLocalState(null);
const [loading, setLoading] = useLocalState(true);
useEffectOnce(() => {
let cancelled = false;
fetch(url)
.then((res) => res.json())
.then((json) => {
if (!cancelled) setData(json);
})
.finally(() => setLoading(false));
return () => {
cancelled = true;
};
}, [url]);
return { data, loading };
}
Conclusion
Compositions are just functions, and everything you know about writing good functions applies: single purpose, explicit inputs, owned cleanup. Master the three primitives, then let your abstractions grow out of repeated patterns rather than speculation.
