feat(clerk-js,shared,react,nextjs): Migrate to useSyncExternalStore
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
ContextProvidersfromuiandreactintoshared/react - Removes the
clerk.addListener()in that provider, also removes most contexts- Breaking change: Several context providers like
UserContextare no longer exported fromshared/react
- Breaking change: Several context providers like
- Adds new hooks that subscribes directly to
clerkviauseSyncExternalStore- These "base" hooks, like
useUserBaseare also reexported asuseUserContextto 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
- These "base" hooks, like
More implementation notes
- Fixes bug in
useClearQueriesOnSignOutas a conditional hook was causing the new tests to fail - Removing
initialAuthStatefromuseAuthalso fixes a bug where the initial auth state was being used during the transitive state- This bug would return
nullinstead ofundefinedon 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
- This bug would return
- Renamed
this.#setAccessors();tothis.#updateAccessors();which now callsthis.#emit()by default- Can pass
skipInitialEmitto skip emitting - This was done because it's important to not let the
clerkstate and the app state tear
- Can pass
this.#emit()- Now saves
this.__internal_lastEmittedResources, souseSEScan read the last resources emitted ingetSnapshot - Supports a new
skipInitialEmitto avoid emitting and re-rendering onuseSES->subscribe
- Now saves
- 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 viasetState- The
SignInRouteshas a fallback, which is to redirect back to/sign-in - After sign in from password in
TanStack Routerspecifically,SignInstayed mounted and re-rendered because ofuseSES, 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
nodesa ref instead - Adding a
useSyncExternalStorewhere we don't use the return value, to make sure<Components />re-renders before the child and unmounts it
- Making the
- The
- In
BaseRouter.tsx, the asyncbaseNavigatewas doingsetRoutePartsas 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,
useSESin a child ran first and similar to the above case, triggered a redirect back to/sign-in - This was fixed by a
flushSyncto ensure the components had properly re-rendered, guaranteeingsetActivedoes 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 testruns as expected. - [x]
pnpm buildruns 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.
π¦ 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
The latest updates on your projects. Learn more about Vercel for GitHub.
| Project | Deployment | Review | Updated (UTC) |
|---|---|---|---|
| clerk-js-sandbox | Preview, Comment | Dec 18, 2025 2:59pm |
π 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.
@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