javascript icon indicating copy to clipboard operation
javascript copied to clipboard

feat(clerk-js,shared,react,nextjs): Migrate to useSyncExternalStore

Open Ephem opened this issue 3 weeks ago β€’ 4 comments

Description

This is the full implementation of PoCs #7194 and #7267.

The main goal of the PR is to refactor the SDKs away from using a top level addListener+state+context combo for auth-state, to using useSyncExternalStore.

  • Merge the ContextProviders from ui and react into shared/react
  • Removes the clerk.addListener() in that provider, also removes most contexts
    • Breaking change: Several context providers like UserContext are no longer exported from shared/react
  • Adds new hooks that subscribes directly to clerk via useSyncExternalStore
    • These "base" hooks, like useUserBase are also reexported as useUserContext to avoid breaking change for these
    • They have the exact same signature, leaving all other hooks untouched
    • We should discuss this, both naming and if we want to do more breaking changes here instead

More implementation notes

  • Fixes bug in useClearQueriesOnSignOut as a conditional hook was causing the new tests to fail
  • Removing initialAuthState from useAuth also fixes a bug where the initial auth state was being used during the transitive state
    • This bug would return null instead of undefined on the page right after sign-in, which put the app in an akward state where it momentarily believed you were still signed out - Could lead to redirects back to /sign-in
  • Renamed this.#setAccessors(); to this.#updateAccessors(); which now calls this.#emit() by default
    • Can pass skipInitialEmit to skip emitting
    • This was done because it's important to not let the clerk state and the app state tear
  • this.#emit()
    • Now saves this.__internal_lastEmittedResources, so useSES can read the last resources emitted in getSnapshot
    • Supports a new skipInitialEmit to avoid emitting and re-rendering on useSES -> subscribe
  • Minor refactors for readability in adjacent code in clerk-js
  • Some type improvements

With all this done, I noticed two timing issues in the bridge between the host React app and the Components app, which is a separate app. Both happened because components that should have unmounted had time to re-render and fire effects, because useSES emitted slightly earlier than setState.

  • In ui/src/Components.tsx, the nodes (current components and which node they should be portaled into) was being set via setState
    • The SignInRoutes has a fallback, which is to redirect back to /sign-in
    • After sign in from password in TanStack Router specifically, SignIn stayed mounted and re-rendered because of useSES, even though the node was gone and the next page had already been rendered
    • This hit the fallback <RedirectToSignIn /> route
    • This was fixed by:
      • Making the nodes a ref instead
      • Adding a useSyncExternalStore where we don't use the return value, to make sure <Components /> re-renders before the child and unmounts it
  • In BaseRouter.tsx, the async baseNavigate was doing setRouteParts as part of the navigation
    • This ensured the state was set before returning to the caller
    • This did not ensure the component had re-rendered as a response to the route change
    • On sign in, useSES in a child ran first and similar to the above case, triggered a redirect back to /sign-in
    • This was fixed by a flushSync to ensure the components had properly re-rendered, guaranteeing setActive does not leave the transitive state before the host app AND the Clerk components are ready

Both of these fixes might be considered controversial, happy for feedback!

Note that the unmount flow has multiple .then() which schedules unmounting into several microtasks even when UI is available. We might want to make this synchronous for even tighter control, but this was not necessary to fix these bugs.

Note also that these bugs result from having multiple React apps. The normal React lifecycle should guarantee they don't happen in host apps.

Background and motivation

Feel free to skip this if you already have familiarity, but as this is a fairly large change, I wanted to write down the full reasoning for this change for posterity.

As I see it, Clerk has different kinds of state. JWT state, piggybacking state and fetched state. Worth noting is that the fetched state has previously used SWR under the hood, but is about to change over to a custom React Query implementation, but both of these already uses useSyncExternalStore and are untouched by this PR.

The JWT state is fairly self-explanatory, and the client piggybacking state is asynchronous state that is included as part of other API-calls as a self-refreshing mechanism. The clerk-js base abstraction updates this for example when it polls for the /tokens, and pushes the changes to the SDKs.

The JWT state and the piggybacking state together represents the foundational "auth state". This represents the user, the active organization etc.

Current solution

clerk-js has an addListener method. The React SDKs subscribes to this in an effect in their <ClerkProvider> and sets an internal state on every update. This state is then massaged a bit and placed on a few different contexts, and the different hooks, like useAuth, useUser etc read from these contexts.

<ClerkProvider> also supports passing in an initialState. If this is passed in, you can access it during SSR, but because there is no good way to prefetch the piggybacking or fetched state currently, only the useAuth state is really feasible to server render.

In Next, doing <ClerkProvider dynamic> automatically provides the initialState for useAuth behind the scenes. The reason we don't always do this is that it opts out of static generation and caching.

Current challenges and issues

Subscribing at the top has a few downsides however. It's currently hard to make things more streamy by allowing initialState to be represented as a promise, as we currently need to await it at the top. This is something we want to do to support cacheComponents and Partial prerendering better, allowing for finer-grained caching. This also opens up for making things suspenseful.

It also doesn't play well with concurrent rendering. A concrete example of this is an active bug that can happen when one subtree suspends long enough during page load that clerk-js has time to load. When it unsuspends, it reads the loaded auth state and not the initial one, causing hydration mismatches.

Another downside is how context is hard to use for fine grained updates, making it hard to implement things like selectors on top of it.

useSyncExternalStore is the canonical way to safely subscribe to an external store, and has none of these downsides.

Checklist

  • [x] pnpm test runs as expected.
  • [x] pnpm build runs as expected.
  • [ ] (If applicable) JSDoc comments have been added or updated for any package exports
  • [ ] (If applicable) Documentation has been updated

Type of change

  • [x] πŸ› Bug fix
  • [x] 🌟 New feature
  • [x] πŸ”¨ Breaking change
  • [x] πŸ“– Refactoring / dependency upgrade / documentation
  • [ ] other:

Summary by CodeRabbit

  • New Features

    • Optional listener subscribe flag to skip initial emission; last-emitted auth/session snapshot exposed.
  • Refactor

    • Hooks and providers reworked to derive auth/org state consistently from initial/server state.
    • Public API surface trimmed (several legacy context exports removed); useAuth now receives options rather than an initialAuthState.
  • Tests

    • Added integration tests and demo page for organization-switching transitions.

✏️ Tip: You can customize this high-level summary in your review settings.

Ephem avatar Dec 09 '25 12:12 Ephem

πŸ¦‹ Changeset detected

Latest commit: 598c13a31f20ec62932cbe07bc442c0394327a57

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@clerk/nextjs Major
@clerk/shared Major
@clerk/react Major
@clerk/expo Major
@clerk/chrome-extension Major
@clerk/react-router Major
@clerk/clerk-js Minor
@clerk/tanstack-react-start Minor
@clerk/agent-toolkit Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/expo-passkeys Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/localizations Patch
@clerk/nuxt Patch
@clerk/testing Patch
@clerk/ui Patch
@clerk/vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

changeset-bot[bot] avatar Dec 09 '25 12:12 changeset-bot[bot]

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Dec 18, 2025 2:59pm

vercel[bot] avatar Dec 09 '25 12:12 vercel[bot]

πŸ“ Walkthrough

Walkthrough

The PR migrates React SDK state from context objects to hook-based external stores using useSyncExternalStore. It adds ClerkContextProvider and InitialStateProvider, new base hooks (useAuthBase, useSessionBase, useClientBase, useUserBase), and derives public accessors (useAuth, useSessionContext, etc.) from those hooks. clerk-js addListener gains ListenerOptions (skipInitialEmit) and exposes __internal_lastEmittedResources. PromisifiedAuthProvider and several legacy context exports (ClientContext, SessionContext, UserContext, OrganizationProvider) and related wrappers were removed; NextJS provider flows were simplified.

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.71% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
βœ… Passed checks (2 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The title clearly and accurately summarizes the main change: a migration from context-based state management to React's useSyncExternalStore for auth subscriptions across multiple packages.

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot] avatar Dec 09 '25 12:12 coderabbitai[bot]

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7411
@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7411
@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7411
@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7411
@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7411
@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7411
@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7411
@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7411
@clerk/express

npm i https://pkg.pr.new/@clerk/express@7411
@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7411
@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7411
@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7411
@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7411
@clerk/react

npm i https://pkg.pr.new/@clerk/react@7411
@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7411
@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7411
@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7411
@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7411
@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7411
@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7411
@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7411

commit: 598c13a

pkg-pr-new[bot] avatar Dec 09 '25 16:12 pkg-pr-new[bot]