Visual Stability: Making Impossible States Impossible In short: Four boolean flags let a component be loading, empty, and errored at the same time. A discriminated union makes that combination fail to compile. Pick the second one. When developers build a UI, they usually mock a perfect response, bind it, and polish the CSS until it looks right. That is the happy path , and it is a small fraction of a component's actual life. Across a working day, a dispatch dashboard spends real time loading, occasionally shows nothing because no tankers have arrived yet, and sometimes shows an error because the logistics service is restarting. If your contract does not describe those states, each consumer invents its own version — and the component jumps around while data arrives, which users experience as the page being broken. The impossible-state anti-pattern Here is the contract almost everyone writes first: Ask the awkward questions: What if and are both true? Spinner, error box, or both? What if is true but has three items? Which one is lying? What if all three are false and is empty — is that loading, or genuinely nothing? There is no right answer, because the API permits states that have no meaning. Every consumer will guess differently, and you will get bug reports that read "sometimes it shows a spinner forever". The fix: one state, discriminated A strong contract makes nonsense fail to compile. Instead of independent booleans, model the states as one value that can only be one thing at a time — and attach the data to the state that actually has data. This is a discriminated union : every branch shares a field, and TypeScript uses it to narrow. Three problems disappear at once: You cannot be loading and errored — the union has one . You cannot claim empty while holding data — is computed from the data. You cannot read unless is — TypeScript will not let you. The template becomes a state machine Every branch is accounted for, and checks the narrowing: inside , type-checks; outside it, the same expression is a compile error. Reserving the space Choosing the right state is half of visual stability. The other half is not moving things when the state changes . A spinner that is 40px tall replaced by a list that is 400px tall means everything below jumps down the moment data arrives. If the user was reaching for a button, they now click the wrong one. Reserve the space in CSS: Better still for lists whose length you know, render skeleton rows at the real row height: The list occupies its final height immediately, and the content fades in without shifting anything. This is not cosmetic. Layout shift is a measured web vital, and it is the difference between an interface that feels solid and one that feels unreliable. "But I thought the parent could just handle loading with an @if" This is the pragmatic choice and it looks tidier — leave the component simple, let the parent decide: For a component used once, this is fine. It stops being fine the moment the component is shared, for three reasons. Every consumer reinvents it, differently. The dispatch dashboard shows a spinner. The farmer portal shows a skeleton. A third screen shows nothing at all and looks broken for 800ms. Your design system now has three loading experiences, and none of them are in the design system. Nobody reserves the space. The swaps a 40px spinner for a 400px list. The component itself was the one thing that knew how tall it was going to be, and it was excluded from the decision. The empty state goes missing. This is the one that actually ships. renders the list, the list renders zero rows, and the user sees a heading with white space under it. Nobody wrote "if the array is empty, say so" because it was not anyone's job — the parent thought it had succeeded, the component thought it was given data. There is also a subtle correctness problem. When the parent owns the states, the component's inputs must be optional to cover the loading case, so becomes and every consumer handles the undefined branch again. Parent owns states Component owns states --- --- --- Loading experiences across 3 apps 3 different ones 1 Layout shift when data arrives Yes No — space reserved Empty state Frequently forgotten Impossible to forget Component's data input , always defined New consumer gets it right If they read the docs By construction The general principle: a shared component should own every state of the data it renders. The parent's job is to say what the state is , not to draw it. Cheat sheet Situation Contract --- --- A request that can be pending, failed, or done One discriminated union with "Empty" from the data — never an input Error detail On the branch only The data array On the branch, , never optional Space during loading Reserve with or skeleton rows Two booleans that cannot both be true You wanted a union Recap Boolean flags multiply into nonsense states. Four booleans is sixteen combinations and twelve bugs. Use a discriminated union so the data lives…