Volume I index
State and Events From Scratch

Derived UI from State

Ishtmeet Singh @ishtms/July 14, 2026/9 min read
#react#state#derived-data#rendering#forms

State should hold information that can change independently. Values that follow directly from current props and state should be calculated during rendering.

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

jsx
const [query, setQuery] = useState("");
const [visibleLessons, setVisibleLessons] = useState(lessons);

Every query edit must update both.

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

jsx
const visibleLessons = filterLessons(lessons, query);

Every render uses the current prop and current state together.

Derive a Count from the Derived Array

Calculate the visible count from the visible list.

jsx
const visibleCount = visibleLessons.length;
jsx
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.

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

jsx
const [isOpen, setIsOpen] = useState(false);
const buttonLabel = isOpen ? "Hide details" : "Show details";
jsx
<button aria-expanded={isOpen}>
  {buttonLabel}
</button>

Do not store the label separately.

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

jsx
const [weeklyGoal, setWeeklyGoal] = useState("3");

Calculate a numeric interpretation.

jsx
const numericGoal = Number(weeklyGoal);
const hasValidGoal =
  weeklyGoal !== "" &&
  Number.isInteger(numericGoal) &&
  numericGoal >= 1 &&
  numericGoal <= 30;

Use the boolean in the current UI.

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

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

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

jsx
const [selectedId, setSelectedId] = useState(lessons[0]?.id ?? null);

Find the object in the current prop array.

jsx
const selectedLesson = lessons.find(
  (lesson) => lesson.id === selectedId,
) ?? null;

Storing both values duplicates the selection.

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

Handle a Missing Derived Selection

The selected ID can refer to a record that was removed.

jsx
const selectedLesson = lessons.find(
  (lesson) => lesson.id === selectedId,
);

The result is undefined when no lesson matches. Render an explicit result.

jsx
if (!selectedLesson) {
  return <p>Select an available lesson.</p>;
}

If removal occurs through a local event, update the collection and selection in that event.

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

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

jsx
const [status, setStatus] = useState("idle");

Supported values can include idle, loading, error, and complete. The UI derives booleans when needed.

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

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

jsx
function LessonTitle({ title }) {
  return <h2>{title}</h2>;
}

Local draft state is appropriate when the child provides an editing session.

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

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

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

Lift State to the Closest Shared Parent

Two sibling components need the same selected lesson ID.

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

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

text
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 data

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

jsx
const [query, setQuery] = useState("");
const [showCompleted, setShowCompleted] = useState(true);
const [weeklyGoal, setWeeklyGoal] = useState("3");

Calculate the list.

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

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

text
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 derived

Source Notes