Derived UI from State
State should hold information that can change independently. Values that follow directly from current props and state should be calculated during rendering.
function LessonSearch({ lessons }) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const visibleLessons = lessons.filter((lesson) =>
lesson.title.toLowerCase().includes(normalizedQuery),
);
return <LessonList lessons={visibleLessons} />;
}The reader can change query, so store it in state. The normalized string and filtered array follow from query and lessons, so calculate them for each render.
No second setter is needed to keep the visible list synchronized.
Duplicate State Creates Synchronization Work
This component stores both the input and its result.
const [query, setQuery] = useState("");
const [visibleLessons, setVisibleLessons] = useState(lessons);Every query edit must update both.
function handleQueryChange(event) {
const nextQuery = event.target.value;
setQuery(nextQuery);
setVisibleLessons(filterLessons(lessons, nextQuery));
}Another parent render can supply a changed lessons array without a query event. visibleLessons then contains a result calculated from the previous prop array.
Remove the duplicate state.
const visibleLessons = filterLessons(lessons, query);Every render uses the current prop and current state together.
If a value can be calculated from current props and state, calculate it during rendering instead of storing another copy.
Derive a Count from the Derived Array
Calculate the visible count from the visible list.
const visibleCount = visibleLessons.length;return (
<section>
<p>{visibleCount} lessons shown</p>
<LessonList lessons={visibleLessons} />
</section>
);Do not store visibleCount in another state position. It changes whenever visibleLessons changes and has no independent user action.
Do not calculate it from the source array either.
const visibleCount = lessons.length;That count can disagree with the list after filtering.
Derive related display values from the same intermediate result so they describe one screen state.
Derive Labels from the Stored Condition
One boolean can determine a button label, ARIA state, and optional content.
const [isOpen, setIsOpen] = useState(false);
const buttonLabel = isOpen ? "Hide details" : "Show details";<button aria-expanded={isOpen}>
{buttonLabel}
</button>Do not store the label separately.
const [buttonLabel, setButtonLabel] = useState("Show details");The label would need an update in every place that updates isOpen. Missing one call creates contradictory UI.
State should store the fact. Rendering should calculate the language and host props that represent the fact.
Derive Form Validity from Raw Input
The controlled number input stores a string because an empty field is a valid editing state.
const [weeklyGoal, setWeeklyGoal] = useState("3");Calculate a numeric interpretation.
const numericGoal = Number(weeklyGoal);
const hasValidGoal =
weeklyGoal !== "" &&
Number.isInteger(numericGoal) &&
numericGoal >= 1 &&
numericGoal <= 30;Use the boolean in the current UI.
<p>
{hasValidGoal
? "Goal accepted"
: "Enter a whole number from 1 to 30"}
</p>The user changes only weeklyGoal. numericGoal, hasValidGoal, and the message are consequences of that string.
Store submission status separately when an asynchronous save begins. Pending and server errors do not follow from the input string alone.
Separate Editing Validity from Submission Feedback
Deriving validity does not decide when an error should be visible. That timing can require independent state.
const [submitted, setSubmitted] = useState(false);
const showGoalError = submitted && !hasValidGoal;hasValidGoal is derived from the field. submitted records whether the user attempted submission, which is a separate event fact.
function handleSubmit(event) {
event.preventDefault();
setSubmitted(true);
if (!hasValidGoal) return;
saveGoal();
}These values change for different reasons. Validity follows the current input. Submission attempt follows an event.
Do not derive submitted from whether the input is non-empty. A reader can edit before submitting or clear the field after submitting.
Store an ID Instead of a Copied Selected Object
Suppose the parent supplies an array of lessons and tracks one selection.
const [selectedId, setSelectedId] = useState(lessons[0]?.id ?? null);Find the object in the current prop array.
const selectedLesson = lessons.find(
(lesson) => lesson.id === selectedId,
) ?? null;Storing both values duplicates the selection.
const [selectedId, setSelectedId] = useState(first.id);
const [selectedLesson, setSelectedLesson] = useState(first);If the current lessons array receives an updated title, the stored object can retain the old title. The ID still identifies which current object should be selected.
The source array remains the current set of lesson records. State stores only the ID of the selected record.
Store a stable record ID for selection and derive the current record from the current collection.
Handle a Missing Derived Selection
The selected ID can refer to a record that was removed.
const selectedLesson = lessons.find(
(lesson) => lesson.id === selectedId,
);The result is undefined when no lesson matches. Render an explicit result.
if (!selectedLesson) {
return <p>Select an available lesson.</p>;
}If removal occurs through a local event, update the collection and selection in that event.
function handleDelete(lessonId) {
setLessons((current) =>
current.filter((lesson) => lesson.id !== lessonId),
);
setSelectedId((current) =>
current === lessonId ? null : current,
);
}The event knows which ID is being deleted and can preserve the state invariant that a deleted record is not selected.
Do not call a setter during rendering merely because the derived lookup failed. Render a supported empty result and repair local state at the event that changed the data.
Avoid Contradictory Boolean State
This state permits impossible combinations.
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [isComplete, setIsComplete] = useState(false);All three can become true at the same time. If they represent mutually exclusive request states, store one status.
const [status, setStatus] = useState("idle");Supported values can include idle, loading, error, and complete. The UI derives booleans when needed.
const isLoading = status === "loading";
const hasError = status === "error";Independent conditions should remain independent. A lesson can be both complete and featured, so one exclusive status should not combine them.
The data model should prevent combinations the UI cannot explain.
Do Not Mirror a Prop Without an Editing Workflow
This component copies a prop only during initialization.
function LessonTitle({ title }) {
const [localTitle, setLocalTitle] = useState(title);
return <h2>{localTitle}</h2>;
}A later parent title does not replace localTitle. If the component only displays the prop, use it directly.
function LessonTitle({ title }) {
return <h2>{title}</h2>;
}Local draft state is appropriate when the child provides an editing session.
function TitleEditor({ initialTitle }) {
const [draft, setDraft] = useState(initialTitle);
// ...
}The prop name initialTitle states that later prop changes do not automatically synchronize. The component needs a save, cancel, and record-switch policy. A different record can start a new editor instance by changing the component's stable key.
Do Not Use an Effect for a Plain Calculation
This full name follows directly from two state values.
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);The component first renders with the previous fullName. After commit, the effect queues another render with the calculated value.
Calculate during the original render.
const fullName = `${firstName} ${lastName}`;Effects synchronize React with external systems. A string calculation within current React data has no external system.
No Effect is needed because every displayed value can be calculated from current props and state during rendering.
Adding state and an effect to a direct calculation creates another render and another rule that can fall out of agreement.
Lift State to the Closest Shared Parent
Two sibling components need the same selected lesson ID.
function ChapterPage({ lessons }) {
const [selectedId, setSelectedId] = useState(null);
return (
<>
<LessonList onSelect={setSelectedId} />
<LessonDetails selectedId={selectedId} />
</>
);
}Store the state in the nearest parent that coordinates both siblings. Each child receives the value or callback it needs.
Do not keep separate selected IDs in both siblings and attempt to synchronize them. One state position produces one current value.
In a larger tree, context or a state library can distribute shared state. Put the independent value in a component or store that can supply every consumer and update path.
Calculate First, Optimize After Measurement
Filtering a small list during render is normally direct and readable.
const visibleLessons = lessons.filter(matchesQuery);Do not add useMemo merely because the calculation sits in a component. Memoization adds dependencies and retains a cached result. It should follow measured cost or a reference contract that requires it.
React Compiler can automatically memoize supported pure component work in configured projects. Memoization does not make duplicate state safe; a cached duplicate can still disagree with its source.
Apply the State Test
Before adding a state position, classify the value.
constant for every render -> module or local constant
supplied by parent -> prop
calculated from current props and state -> derived value
changed independently by an event -> state
retained by the browser control -> uncontrolled DOM value
supplied by an external server or cache -> external dataThe categories can change with the feature. A hard-coded lesson list may become server data. A display string may become an editable draft. Change where a value is stored when its behavior changes, not before.
Build the Derived Lesson Planner
Store only the independent controls.
const [query, setQuery] = useState("");
const [showCompleted, setShowCompleted] = useState(true);
const [weeklyGoal, setWeeklyGoal] = useState("3");Calculate the list.
const normalizedQuery = query.trim().toLowerCase();
const visibleLessons = lessons.filter((lesson) => {
const matchesText = lesson.title.toLowerCase().includes(normalizedQuery);
const matchesStatus = showCompleted || !lesson.complete;
return matchesText && matchesStatus;
});Calculate goal validity.
const numericGoal = Number(weeklyGoal);
const hasValidGoal = Number.isInteger(numericGoal) && numericGoal > 0;The count, empty message, button state, and summary all follow from these results during the same render.
Lesson Check
The planner now contains three stored values and several calculated values without synchronization effects.
query, checkbox, and raw goal string are state
normalized query is derived
visible lesson array is derived
visible count is derived
numeric goal and validity are derived
labels and empty output are derivedSource Notes
- React documentation, Choosing the State Structure
- React documentation, Sharing State Between Components
- React documentation, You Might Not Need an Effect
- React Compiler documentation, Introduction