175: React with TypeScript: Props, Events, Refs, Forms, and Generic Components
Learning outcomes
By the end of this lesson, you can:
- explain and apply component props in a realistic implementation;
- explain and apply children and render props in a realistic implementation;
- explain and apply DOM events in a realistic implementation;
- explain and apply refs in a realistic implementation;
- explain and apply forms in a realistic implementation.
These outcomes are connected. A component receives data through props, may receive renderable content or a rendering function through children and render props, and reports user interaction through events. Refs provide an intentionally imperative escape hatch, while forms bring together events, raw user input, validation, and domain state. Generic components add another layer when a reusable component must preserve relationships between its inputs and outputs.
Prerequisites and retrieval
This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before reading, retrieve one concrete example from a previous project where one of these concerns appeared. Perhaps a form stored input as strings, a reusable list accepted a callback, or a button needed to expose a DOM action. Recalling a real example gives the types a job to do instead of turning the lesson into a vocabulary exercise.
The goal is not to memorize terminology. It is to make a defensible decision inside a strict TypeScript codebase. The compiler can help model domain invariants, but static types do not replace runtime validation. Data from a user, a server, local storage, or a URL is still untrusted at runtime, even when the code receiving it has a TypeScript type.
Terminology
- Component props: Model required and optional props directly. Prefer discriminated unions when particular prop combinations are mutually exclusive, so invalid combinations are rejected at compile time.
- Children and render props: Use
ReactNodewhen a component accepts renderable children. Use a precise function type for a render prop when the component supplies data to the caller's renderer. - DOM events: Use the event type matching both the element and the event kind, such as
ChangeEvent<HTMLInputElement>for an input change handler. - Refs: Use the appropriate element or instance type and account for
nullduring lifecycle transitions. A ref may be empty before mounting, after unmounting, or while a value is being replaced. - Forms: Keep raw string input state separate from parsed domain values. The text in an input is not automatically a valid number, date, identifier, or business value.
- Generic components: Tables, selects, and lists may benefit from generics when a component preserves a relationship, such as the relationship between an item type and its key or renderer. A generic is useful when it prevents a real class of mismatches, not merely because the component can be made more abstract.
Mental model
Treat React with TypeScript: Props, Events, Refs, Forms, and Generic Components as a design problem with observable inputs, outputs, invariants, and failure modes. Props and events describe a component's public boundary. Children and render props describe how the caller supplies presentation. Refs represent a controlled connection to an underlying DOM node or imperative instance. Forms are boundaries where untrusted strings become validated values. Generics preserve relationships across reusable APIs.
React's public types are already rich. Application code should model component contracts precisely without over-annotating JSX or reaching for outdated helper types. A plain function component with an explicit props parameter is often clearer than a broad abstraction that silently adds assumptions. A strong implementation makes those assumptions visible, narrows uncertainty at boundaries, and leaves enough evidence—tests, types, constraints, metrics, or diagrams—to show why the design is safe.
A useful interview and production sequence is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
For example, do not start by choosing a generic select library because the requirement says “let a user choose a project.” First clarify whether the value is an ID or a whole object, whether no selection is valid, how loading and server errors appear, and whether the list can be refreshed while the menu is open. Then choose the props, event handlers, form state, and validation boundary that make those constraints explicit.
Do not jump from a requirement directly to a library call. First state what must remain true. Then choose the mechanism that enforces it. TypeScript can prevent a caller from passing a missing prop or an impossible prop combination; it cannot prove that a server response really matches the declared type.
Deep dive
1. Component props
When a component accepts data or callbacks, the props object is its public contract. Model required and optional props directly, using names that communicate the component's behavior. Optionality should mean something real: for instance, an omitted description may mean that no description is rendered, while an optional callback may mean that the interaction is intentionally disabled. Do not make every prop optional just to make callers compile.
Prefer discriminated unions when prop combinations are mutually exclusive. If a component either renders a link or performs an action, a single broad type with several optional fields permits invalid states such as “link mode with no URL” or “button mode with a URL that is silently ignored.” A discriminant such as kind: "link" | "button" lets TypeScript narrow the remaining fields together and makes the runtime branching match the type model.
Avoid broad React.FC assumptions when plain function components communicate the contract clearly. The important question is not which component shorthand is fashionable; it is whether the props, children behavior, return value, and defaulting rules are visible to the reader and enforced by the compiler.
Decision rule: Use component props deliberately when they make the contract or invariant easier to prove. If a type annotation only reduces keystrokes while hiding an assumption, prefer the more explicit design.
2. Children and render props
children and render props solve related but different problems. children lets a caller provide renderable content inside a component, while a render prop is a function that the component calls with data or state. Use ReactNode when the component accepts renderable children rather than assuming that children must be a string or a single element. React's renderable values include more than one concrete element shape.
A render prop should describe the data the component supplies and the renderable result it expects back. That function type is part of the component's contract. Do not use any for children just because JSX is flexible; doing so removes useful checking at exactly the boundary where the caller and component need to agree.
This is where people usually get confused: ReactNode describes content that React can render, not arbitrary application data. If a component needs a list of domain objects, type that list as domain objects. If it needs a function that maps one object to a row, type the function's input and output precisely.
Decision rule: Use children and render props deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
3. DOM events
Event typing becomes useful when a handler needs to inspect the event target, prevent default behavior, or translate browser state into application state. Use the event type matching the element and event kind, such as ChangeEvent<HTMLInputElement>. A text input, checkbox, select, and form submission do not necessarily expose the same target properties or event semantics.
Prefer typing the handler at its declaration rather than casting event.target. A cast tells the compiler to trust you; a correctly typed handler gives the compiler the information it needs to check the implementation. For a form submit, the event type should support the form-specific operations you perform. For a controlled input, read the value in the handler and keep the state representation intentional.
The browser still supplies runtime values. TypeScript does not validate that an event came from the element you expected if untyped code, a ref, or an external integration bypasses the normal path. Type the boundary accurately, and still validate values when they cross into a domain operation.
Decision rule: Use DOM events deliberately when their type makes the contract or invariant easier to prove. If a cast only suppresses an error while hiding uncertainty about the target, prefer a correctly typed handler or an explicit runtime check.
4. Refs
Use a ref when a declarative prop or state update is not the right tool for the job: focusing an input, measuring an element, integrating with a browser API, or coordinating with an imperative third-party widget are common examples. Use the appropriate element or instance type, and account for null during lifecycle transitions. A node is not available before it mounts, and it is no longer safe to use after it unmounts.
Refs do not make a value reactive. Updating ref.current does not ask React to render again, so refs are a poor substitute for state when the screen must respond to a change. They also do not remove the need to respect the component lifecycle. Code that reads a ref should have a clear reason for when the node exists and what happens when it does not.
forwardRef patterns vary with React versions; follow current React guidance rather than copying legacy boilerplate. The underlying design question remains stable: expose the smallest imperative surface the parent needs, and keep normal data flow in props and state.
Decision rule: Use refs deliberately when they make the contract or invariant easier to prove. If a ref is being used to hide state that should drive rendering, prefer state or props instead.
5. Forms
Forms combine several boundaries that are easy to blur together. The browser gives you raw strings, while the application may need a number, date, enum, or structured object. Separate raw string input state from parsed domain values. This lets the UI represent an empty field, an incomplete number, or an invalid date without pretending that each intermediate value is already valid domain data.
Validation also has two parts. Client-side validation improves feedback and prevents obviously invalid submissions, but it is not an authority boundary. Schema or form libraries can bridge runtime validation and TypeScript types, yet server validation remains authoritative because the client can be modified and requests can be sent without the UI.
Model submission state explicitly when the interface needs to distinguish idle, submitting, success, and failure. A discriminated submit state is more useful than a collection of loosely related booleans that can accidentally claim that a form is both submitting and complete. Preserve server errors in a form the UI can display without confusing them with local field errors.
Decision rule: Use forms deliberately when the representation, validation boundary, and failure states are explicit. If a type annotation makes raw input look validated when it is not, separate the stages instead.
6. Generic components
Generics are valuable when a reusable component preserves a relationship between values. A table may accept rows of T and column renderers that receive T. A select may accept options of T, a way to obtain a stable key from T, and a label renderer that also receives T. That relationship prevents a renderer for one kind of item from being paired accidentally with another kind.
Do not make a component generic merely to demonstrate abstraction. If callers must manually specify complex types, or if the generic does not constrain a meaningful relationship, the API may be harder to use than a focused component. Good inference matters: callers should normally get T from the values they pass rather than having to spell out an elaborate type argument.
Decision rule: Use generic components deliberately when they preserve relationships such as item type to key or renderer. If the generic only reduces typing while hiding an assumption, prefer the simpler explicit design.
Worked example
Consider a strict TypeScript codebase where the compiler is used to model domain invariants without pretending that static types replace runtime validation. Start by writing the requirement in one sentence, list the input and output contracts, and identify which of the concepts above owns each failure mode.
Suppose the requirement is “index the loaded records by a caller-selected property.” The input is a read-only collection of items and a function that obtains a property key from each item. The output is a record whose keys are those property keys and whose values are the corresponding items. That contract is more precise than accepting string, because PropertyKey includes the key types JavaScript objects support.
The important move is separation: parsing or validation belongs at the boundary; domain rules belong in the domain or service layer; persistence rules belong in the database or repository; presentation rules belong in the client. Mixing these concerns makes a happy-path demo look shorter, but it makes edge cases much harder to reason about. The utility below is concerned only with transforming already-provided items. It does not validate their origin or decide whether duplicate keys are acceptable.
function indexBy<T, K extends PropertyKey>(
items: readonly T[],
keyOf: (item: T) => K
): Record<K, T> {
return Object.fromEntries(items.map(item => [keyOf(item), item])) as Record<K, T>;
}
There are several details worth noticing. T represents the item type, so keyOf receives the same type that appears in the input array. K is constrained to PropertyKey, which is the set of values that can serve as object property keys. readonly T[] allows callers to pass an immutable view without requiring the function to mutate it. The assertion on the result is necessary because Object.fromEntries cannot infer this particular relationship from the mapped entries on its own; it should not be read as runtime validation.
Walk the example with at least four cases: the normal path, an empty or missing value, a duplicate, retry, or concurrent path where relevant, and a dependency failure. For this utility, an ordinary collection should produce one lookup entry per key. An empty collection produces an empty record. If two items produce the same key, later entries overwrite earlier entries because that is how the object construction behaves; if that is not acceptable, the requirement needs a different representation or an explicit duplicate check. A dependency failure may occur before this function is called, such as a failed request that leaves no records to index.
For each case, state which layer detects the problem and what the caller observes. The utility can observe a duplicate only if it is designed to check for one; it cannot determine whether a missing item is a network failure or a valid empty result. This is the level of explanation expected in a senior code review or technical interview: describe the behavior, the owning layer, and the observable consequence rather than stopping at “the types compile.”
Production perspective
Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high cardinality. A component that looks correct with three local options may render poorly or become expensive with thousands of options. A form that handles local validation may still receive a server response in an older shape after a deployment.
Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after you can identify the bottleneck or risk with evidence. Generic types can prevent mismatched renderers, but they do not automatically make rendering cheap. A ref can focus an input, but it does not solve focus behavior for a component that is conditionally unmounted. A type-safe form can still submit a request the server rejects.
When the topic involves an external dependency, define a timeout and cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume the client can be modified and the network input is untrusted. Keep authentication, authorization, validation, and persistence guarantees on the server rather than treating a disabled button or a client-side type as enforcement.
Guided lab
Build an accessible generic select component and a typed form with raw input, schema validation, server errors, and a discriminated submit state. The select should preserve the relationship between its option type, its key function, its label renderer, and its selected value. The form should make it possible to display an empty or invalid raw value without claiming that the domain value is valid.
Include accessible labels and keyboard-usable controls. Test both the component contract and the runtime boundary: compile-time invalid prop combinations should fail in a type-checking test, while malformed input and server errors need runtime tests. Keep the server as the authority for the final validation decision.
Complete the lab with this discipline:
- Write the requirement and two non-requirements. For example, decide whether the select owns filtering and whether it supports multiple selection.
- List input, output, and error contracts before implementation.
- Implement the smallest correct vertical slice.
- Add at least one invalid-input test and one edge-case test.
- Instrument or inspect the behavior instead of guessing. Use the compiler, test output, rendered DOM, and network or server logs as appropriate.
- Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
- Explain one alternative design and why you did not choose it.
- Record a short “what would break at 10× scale?” note. Consider option count, rerenders, request volume, and the size of the validation payload.
Edge cases and failure modes
- Component props: Test absence of required data, malformed input at runtime, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Include invalid mutually exclusive prop combinations in type-level tests.
- Children and render props: Test absent children when they are optional, render functions receiving the expected data, malformed external values, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- DOM events: Test absence or unexpected values, malformed input, duplicate events, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Verify that the handler reads the intended target and does not rely on an unsafe cast.
- Refs: Test the ref before mount, after unmount, and when the target changes. Also test absent or malformed integration input, duplicate setup or cleanup, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Forms: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Cover empty values, parse failures, client validation, server rejection, cancellation, and a successful submission.
Common mistakes and debugging
- Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong. A generic select copied from another project may model the wrong value shape or ownership of validation.
- Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary”
anyvalues. These choices make the compiler quiet without making the runtime safer. - Testing only the happy path and therefore discovering contracts only after integration. Forms and refs especially need lifecycle and failure tests, not just a successful click.
- Optimizing before measuring, or selecting a scalable mechanism without a scale requirement. Generics and memoization do not automatically address a rendering bottleneck.
- Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees. A disabled control is a user-interface decision, not a security boundary.
For debugging, reproduce the smallest failing case, inspect the actual value or execution plan, trace the boundary where the invariant first becomes false, and fix the owning layer rather than adding a downstream patch. In a component, inspect the props and rendered DOM. For an event, inspect the actual target and event sequence. For a ref, check mount and cleanup timing. For a form, compare raw input, parsed value, request payload, server response, and submit-state transition. The compiler can locate static contract failures; tests and runtime inspection are still required for malformed data and lifecycle behavior.
Interview questions
- What problem do component props solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do children and render props solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do DOM events solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do refs solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do forms solve, and what trade-off or failure mode would make you choose a different approach?
Answer each question with more than a definition. Name the boundary, describe one invariant, give a small implementation example, and explain what you would inspect when the behavior fails. A strong answer also distinguishes compile-time guarantees from runtime validation and identifies when a simpler design would be safer.
Checkpoint
Without notes, explain React with TypeScript: Props, Events, Refs, Forms, and Generic Components to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code.
As a final check, identify which values in your example are trusted only because of TypeScript and which values are validated at runtime. If you cannot draw that boundary, the implementation is not finished yet.
Mastery checklist
- I can define the core terms precisely.
- I can choose a design from requirements instead of from habit.
- I can implement and test the normal path and edge cases.
- I can explain the runtime, storage, or complexity cost.
- I can identify which layer owns validation, errors, and recovery.
- I can compare at least two reasonable alternatives.
- I can explain how the design changes at larger scale or stricter reliability.
