This post has been first published as an X Article. This version contains small updates for readability.
Great UX means the website works as you’d expect at all times. With fast internet, it should feel instant, with slow internet, it should feel decent.
This is one of the things AI doesn’t get right (yet?). You’ll 🫵 need to test your own website in different failure states and then make your agent brainstorm fixes. Many users of the internet aren’t always on a stable 5G connection or on an iPhone 17 Pro. Everyone at some point takes a train to somewhere, is deep in the woods, or walks through the skyscraper area in NYC. At some point internet gets slower and often it’s right when you need it to be fast.
Table of contents
Filters & good UX💅
As a performance engineer, it’s my passion to do such testing. Recently, I’ve done just that for our dynamic filters feature at Framer (my colleague @pabcrab wrote about those here).
TL;DR filters:
- Allow users to … filter, by ticking checkboxes, selecting something from a dropdown or by typing into a search field
- We store the filters in the URL (query string) of the website
Storing filters in the URL is good UX, it allows visitors to share the link or bookmark the filtered state. When they (re-)open the link, the state where they left is restored. This itself though is the bare minimum I as a user would expect. Vercel’s design guidelines mention this too: UI state should be in the URL where possible, and inputs shouldn’t lose their input after hydration.
What do filters need for great UX?
I asked myself: What’s missing for great UX in this case?
1️⃣ First: filters are a particularly sensitive topic because users often interact with filters immediately upon page load (e.g., searching for content).
2️⃣ Second: Let’s think back to the start, how would filters behave in the “slow internet” / “slow device” scenario: You open the link, you tick some filters and then the website should filter. Right?
What’s happening here? Or rather, what isn’t?
Framer websites use React. Basically, while the website is in the state between “user can interact with the site” and “React ready” (or rather hydration has started), users can modify inputs without React seeing them. It won’t, because the related event listeners might not have been attached yet, or maybe the JavaScript bundle hasn’t even loaded yet.
This creates a disconnect: the UI shows the user’s selection, but the application state (and URL query parameters) doesn’t update. Bad UX. Your users don’t know whether their internet is slow, or your website is slow, or their device is slow. So they wait for nothing - or maybe even leave the page because it feels slow. Exactly what you see in the demo above.
Any framework can suffer from this. So this is not something React is to blame for. You can spot this behavior on lots of JavaScript-heavy sites. Hence why I’m writing this post: Let’s make the web great(er) again.
🔁 Replaying pre-hydration input
I’m of course not the first one to think about this. In fact, it has been reported a few times in the React repo1,2. Curiously, React does have a native solution for this problem, but it’s only enabled in Meta’s React Native build (as of 2026-05-17). Why? Not sure. In any case, this means we’ll want to build something that replays user input that happened before hydration started. Or in other words: Replaying pre-hydration input.
Usually, React fires onChange when an input changes. But if the user stopped typing before React has attached the event listeners to the DOM, onChange never fires. So when a user interacts with a filter input before React has finished hydrating the page, the browser correctly displays the user’s input in the DOM, but any React component never “sees” it.
useReplayPreHydrationInput()
I’ve built a custom hook to fix this. It detects when DOM values differ from React’s intended values during hydration and manually dispatches synthetic events to “replay” the user’s input. This ensures that:
- User input is preserved in the UI (React’s default behavior)
onChangehandlers fire with the correct values (custom replay mechanism)- URL query parameters update to reflect the filter state
During hydration, we also have to make sure the hydration step sees the DOM exactly how it got server-side rendered. This means, a naive useState(() => { ... read from query string ... }) won’t cut it, because it’ll cause a hydration mismatch / error.
Instead, we’ll use useSyncExternalStore in a clever way to detect whether we’re in the hydration pass or the render after:
// returns false during SSR, true on the client
const isHydrationFn = () => typeof window !== "undefined"
const noopSubscribe = () => () => {}
const returnFalse = () => false
// returns true *only* during the hydration render pass
function useIsHydration() {
return useSyncExternalStore(noopSubscribe, returnFalse, isHydrationFn)
}
Now what’s left is tricking React into thinking there was actual user input. This isn’t as easy as it sounds. What I do in the code snippet below is this:
- Compare the current DOM value with React’s intended value
- If they differ, dispatches synthetic events to trigger React’s change handlers
function useReplayPreHydrationInput(value) {
const didReplayRef = useRef(false)
const elementRef = useRef(null)
const isHydration = useIsHydration()
useEffect(() => {
if (!isHydration) return
const element = elementRef.current
if (didReplayRef.current || !element) return
didReplayRef.current = true
const currentDOMValue = element.value
if (currentDOMValue === value) return
// For text inputs, React only calls `onChange` if it sees the value has changed
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value")?.set
if (!nativeInputValueSetter) return
// Set a different value to make React think the value has changed.
nativeInputValueSetter.call(element, "")
element.dispatchEvent(new Event("change", { bubbles: true }))
// React batches events, so we need to queue a microtask to make sure the event is seen only
// after React "completes" the batching.
queueMicrotask(() => {
// Set back to the intended value
nativeInputValueSetter.call(element, currentDOMValue)
element.dispatchEvent(new Event("change", { bubbles: true }))
})
}, [isHydration])
return elementRef
}
Here’s how it looks in action:
Attention to those details makes a difference for your visitors. And all Framer sites get this great UX by default.