Skip to content

Conversation

@TkDodo
Copy link
Collaborator

@TkDodo TkDodo commented Nov 19, 2025

defaultValue is used for uncontrolled components - those that do not have an onChange handler. You usually either pass defaultValue (fully uncontrolled), or you pass value + onChange (fully controlled)

uncontrolled only makes sense in forms where you take the “html” value from the form submit handler, which is not something we do right now. All usages except one in production are controlled usages.

The one usage in gsApp/views/subscriptionPage/usageLog.tsx passes a defaultValue decoded from the url and has an onChange handler that navigates. This will work just the same when passing value. Actually using nuqs would make this explicit.

Note: This is a preparation for a larger refactoring / perf improvement of compactSelect to avoid rendering all options eagerly.

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Nov 19, 2025
@TkDodo TkDodo marked this pull request as ready for review November 19, 2025 13:43
@TkDodo TkDodo requested review from a team as code owners November 19, 2025 13:43
@TkDodo TkDodo requested a review from gggritso November 19, 2025 13:43
@TkDodo TkDodo merged commit 4bc8063 into master Nov 19, 2025
49 checks passed
@TkDodo TkDodo deleted the tkdodo/ref/make-compactSelect-always-controlled branch November 19, 2025 14:58
TkDodo added a commit that referenced this pull request Nov 24, 2025
This PR generally aims to improve performance of the `compactSelect`
component by _not_ rendering all options eagerly. This is achieved with
a simple conditional rendering, however, some pre-requisites are
required to get that going.

> Why did we render all those options even though the select was closed?

Good question! We did that because the `compactSelect` component
internally holds a `<List>`, which managed its state internally. That
state is (was) bubbled up to the parent (with some effects, inverting
the parent-child data flow into child-parent), so that the parent could
create the `label` of the select trigger. For that, it needed to render
all children, otherwise the bubble-up didn’t work.

This just shows why child->parent dataflows are bad design. We need to
render e.g. 1000 options just to get to the correct label of a button.
It’s not react. Furthermore, the whole internal state of `<List>` is
unnecessary, because we use the component in a fully controlled way
exclusively. The parent has the state already! Another reminder that
state duplication & syncing is the root of all evil.

So now, we’re using the parent state to calculate that and remove the
internal state. That means we can also conditionally render options only
when the select is opened. Getting there was painful:

First, we needed to make sure that we disallow uncontrolled usage of
this component:
- #103637
And make it required that we pass `value` and `onChange`:
- #103654

Then, there’s `CompositeSelect`, which shares some internals with
`CompactSelect`, however for those, the parent doesn’t pass the full
state to `CompositeSelect` itself because it’s built up of `Regions`.
The good news is that we never use the default trigger label, so we
don’t need to calculate it based on the current selection. To make sure
we don’t do this in the future, `CompositeSelect` now has a required
trigger:
- #103713

Lastly, there’s `clearable`. The `Clear` button appears _outside_ of
`List`, so it doesn’t have all the information necessary to perform a
clear if we remove the reverse data flow. I changed how `clear` works by
just triggering an `onChange` with an empty value. Also, types were a
mess and that’s now fixed:
- #103720

With that, I could finally remove the internal `useLayoutEffect` that
synced state from the `List` child with the `Control` parent.

### Test Changes

Then, many tests failed. Note that I checked all failing tests in the
product before I changed them, and they all work fine. A couple of
thoughts on why the tests failed:

- They sometimes relied on the internal state management and mocked
`onChange`. Now, without `onChange`, there is no state change happening.
I’ve changed some tests to do some basic `useState` around the component
(like we do in the product, too).
- They sometimes had tests that relied upon all option popups being
rendered, so they were accessing the Nth search field. Now they are no
longer in the DOM because of conditional rendering so all of those
needed to update.
@github-actions github-actions bot locked and limited conversation to collaborators Dec 5, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants