Architecture
Each signed-in account is stored in the Reduxaccounts map, keyed by user ID. The activeAccountId field tracks which account is currently active, and the map is persisted by the AccountManager your platform package mounts — see Where accounts are stored.
When the user switches accounts, the SDK exchanges the target account’s stored refresh token for a live session before it tears the current one down. If that fails — an expired or revoked stored token, for example — the switch rejects and the account you were using stays signed in; see When a transition fails.
Hooks
Reading Account State
accounts is a StoredAccount: the profile summary — id, name, username, email, avatar — plus two credential markers, tokenExpiresAt and needsReauth, and one notification marker, needsPushRebind.
username, email and avatar may be null, and username may be absent entirely on an entry stored by an older SDK version — absent means unknown, not “the user has no username”.
Knowing which stored accounts still work
A stored account can stop working while it sits in the switcher. The two markers let you show that before the user taps it, and you need both:needsReauth clears the moment the account is successfully activated again — by a switch that works, or by signing into it afresh. A switch that failed because the request never reached the server does not set it: a flaky network is not a dead account.
A third marker, needsPushRebind, is about notifications rather than credentials — see Push Notifications Per Account.
Neither marker is a guarantee. needsReauth: false with a future expiry means nothing has gone wrong that the SDK knows of, so a switch can still fail — and when it does, the entry stays in the map precisely so you can render a re-auth affordance for it. See When a transition fails.
Where Accounts Are Stored
Persistence is owned by anAccountManager component that each platform package mounts inside its provider. It carries that platform’s storage adapter, which is why @sublay/core’s own providers cannot supply one.
The five-account limit is a real limit, not an approximation
Expo’s SecureStore documents a 2048-byte ceiling per value, and a whole account map does not reliably fit inside one.@sublay/expo therefore writes one value per account behind a small index value, so five accounts fit comfortably with each value well inside the limit.
If a single account entry ever did exceed the budget, the adapter sheds avatar first, then email, and logs what it dropped — those are the two unbounded fields. id, name, username, the refresh token and the two re-auth markers are never shed: an entry missing those would load but be unusable, or would silently claim to be healthy.
Upgrading keeps your users signed in. A value written by a previous release — the whole map as one value — is converted to the new layout the first time the app loads it, with the accounts and the active selection intact. The conversion runs once and is invisible: later launches read the new layout directly. A stored value that is not a recognizable account map (corrupt bytes, a truncated write) is still read as signed-out rather than guessed at, so those rare installs sign in again once. React Native (Keychain) and web (
localStorage) have no comparable limit, never changed layout, and are unaffected.Integration mode persists too
Apps that bring their own Redux store useSublayIntegrationProvider. Import it from your platform package, not from @sublay/core — core’s version has no AccountManager and therefore no storage, so the account map would live only in memory and multi-account would not survive a relaunch. See Redux Integration.
Adding a New Account
CallingaddAccount clears the current auth state, which causes the app to display the sign-in UI. The previously stored accounts remain in the accounts map and are not affected. After the user signs in to the new account, it is added to the map automatically.
It clears the session and nothing else: the account the user came from stays selected, and signedOut is untouched. So abandoning the flow — backing out and quitting — leaves them where they were, and the next launch restores that account. On the web this also means opening the flow says nothing to the app’s other tabs, which keep their sessions.
canAddAccount is false when 5 accounts are already stored.
Switching Accounts
Removing an Account
activeAccount is null.
Removing, signing out and deleting an account all leave activeAccount as null rather than landing the user inside a different identity, so your app owns what comes next.
Removing an account sends a sign-out request that revokes the refresh token family and unbinds that account’s push notifications on this device, in a single server transaction.
Signing Out All Accounts
signOutAll sends a sign-out request for every stored account’s refresh token — each one revoking that account’s token family and unbinding its push on this device — and then clears local auth state.
When a Transition Fails
Every path that establishes a session for a stored account —switchAccount, and the automatic restore at app launch — has to exchange that account’s stored refresh token for a live access token. That exchange can fail: the token expired, the server revoked it, or the stored entry is unusable.
switchAccount rejects, and nothing else changes. The target’s credential is proven out of band before the current session is torn down, so a failure is a complete no-op against what you were using:
- The previously active account is still active, with a live session — same tokens, same user, same cached data. Nothing was signed out.
- Both account entries survive. The account that failed to restore stays in the map, which is what lets you render a “session expired — sign in again” affordance for it, and its
needsReauthmarker is set when the server was the one that refused. - If nothing was active when the transition was attempted — the picker state the user lands in after signing out — nothing is selected and the app is marked signed out, so the next launch does not silently activate a different account.
Telling a dead account from a dead network
AccountTransitionError is the typed error an account-transition failure rejects with — from
switchAccount, removeAccount and activateStoredAccount alike, and from
setAccountPushEnabled for the one failure it shares
(accountNotFound). It extends Error, so
message and instanceof Error work as usual, and it carries two independent boolean
discriminants, because the three failures it covers call for three different responses.
Check with instanceof before reading those flags, because not every rejection is one:
removeAccount rejects with the underlying error, unchanged, when the server refuses to unbind
the account’s push notifications — that is the atomic-removal refusal described under
Removing an Account, and it is reported as the server sent it.
switchAccount and removeAccount also throw a plain Error when no projectId is configured.
boolean
true when the server refused the stored credential — expired, revoked, reuse-detected,
invalidated by a password change or a remote sign-out-all — or when the stored entry carries no
usable credential at all. This is the only case in which needsReauth is set on that account.false when the exchange never got an answer, or the rotated successor could not be persisted.
Nothing is marked, and the same account may well work on the next attempt.boolean
true when the operation named an account that is not in the stored map at all — a stale id,
typically from a switcher rendered off a snapshot the map has since moved past.Nothing was attempted and nothing was marked: no network call went out, no session was touched,
signedOut is left exactly as it was, and re-authenticating would not help, because there is no
entry to authenticate. Refresh your account list from useAccounts() rather than prompting a
sign-in.The two flags are never both true — an account that is not stored has no credential to have
been refused — and both are false for a transport failure.string
The server’s reason when it gave one, otherwise
ACCOUNT_TRANSITION_FAILED_MESSAGE. Both the
class and that constant are exported, so you never have to match on a string you do not own.A switch still costs exactly one token exchange. The refresh endpoint
rotates the token presented to it, so validating first replaces the old
post-swap refresh rather than adding a second call.
removeAccount rejects on the same terms, and so do signOutAll and useAuth().requestNewAccessToken. None of the four signatures marks them as fallible, so nothing warns you at compile time — await them inside a try/catch, or an unhandled rejection surfaces as a React Native redbox or a browser unhandledrejection.
At app launch, the two failures part company:
- The server refused the stored credential. The app lands signed out with the account entries intact — never silently switched into a different account — and that account is marked
needsReauth, so the switcher can label it without the user having to discover it by tapping. - The server was never reached. Nothing is torn down, nothing is marked, and the account stays selected. Opening the app on a train does not sign anyone out; the session is restored on the next launch, or sooner by the automatic refresh on the first request that takes a
401/403.
Durable Signed-Out State
“No account is active” has two meanings, and the SDK tells them apart across relaunches:signedOut is read from useAccounts() and is persisted alongside the account map, so it survives a relaunch. It clears the moment any account is successfully activated.
Push Notifications Per Account
Every signed-in account on a device is bound to that device separately, so a device holding four accounts receives push for all four — and a message that reaches two of them arrives as two notifications, each stamped with its ownrecipientUserId. Read that key in your tap handler before routing, or a tap lands in the wrong session; see the SDK push page.
Each account has its own switch
useAccountPushToggle.
Signing out unbinds automatically
Once this device has a stored push identifier,signOut(), removeAccount() and signOutAll() each send it with their sign-out request, and the server removes that account’s push binding in the same transaction as the session teardown. Either both happen or neither does, and “neither” is reported rather than swallowed — see the warnings above. Devices with no stored identifier send none, have no unbind to protect, and sign out best-effort.
The identifier is device state and does not imply that the signing-out account ever opted into push. On native, usePushRegistration records one on mount for any device that already holds OS notification permission — that is what lets an app upgrading from an older SDK release still unbind the bindings it created back then. When the account had no binding the unbind removes nothing, and the sign-out is indistinguishable from one that carried no identifier at all. A device whose notification permission was never granted stores nothing and sends nothing.
The permission check is bypassed exactly once per device, on the first mount that reads an identifier, and only on a device that already has at least one stored account and no identifier yet. Turning notifications off in system settings does not invalidate a push token or remove a binding — the provider still accepts the notification, the OS simply does not show it, and the server prunes a token only when it reports the app uninstalled. So a device that registered on an older SDK release and has since revoked permission would otherwise hold a binding that nothing could reach: no identifier means no unbind on sign-out, and re-enabling notifications later would deliver that account’s notifications to a device nobody is signed into. The one-time read closes that. It cannot prompt (only adapters that read a value the OS already holds participate — web never does), and the SDK records that it has run, so it does not repeat on later launches. A device whose account storage was empty the first time the SDK ran is never eligible: it cannot be carrying a binding from an older release.
Do not call usePushRegistration().unregister() in a logout flow. It is a durable per-account preference, so using it as cleanup permanently silences that account’s next session, and sign-out already unbinds. See Turning push off.
When notifications are paused
When the OS rotates the device’s push token, every binding on the old token is dead. As long asusePushRegistration is mounted with an adapter that reports rotations, the SDK notices and records the new identifier. The active account is re-bound on the spot.
Mount that hook at your app root, not on the settings screen that calls register(): rotation handling lives exactly as long as the hook is mounted, so a screen-scoped mount only covers the seconds that screen is open and a rotation anywhere else in the app is missed. See usePushRegistration. Every other push-enabled account is marked as needing a re-bind and repaired the next time the user switches into it, using the live session that switch establishes.
The marking is deliberate rather than a shortcut. Re-binding a background account would mean spending its stored refresh token on a one-time-use exchange, in the background, for an account the user is not looking at — and an interruption in the middle of that exchange locks the account out permanently. The cost is that a push-enabled account nobody opens goes quiet after a rotation until it is next opened, which is what the marker is for:
needsPushRebind clears as soon as the account is re-bound. It is not needsReauth and the two should not read the same in your UI: needsReauth means the credential is dead and the user must sign in again, while needsPushRebind means the account works perfectly and is merely quiet. It is only ever raised for accounts that explicitly enabled push — an account that never asked for notifications has none to pause.
See Automatic token rotation for which platforms report a rotation at all.
Reaching the Account Limit
Signing in a sixth account is refused, with an error that names the cause: this device already remembers the maximum number of accounts, and signing out of one is what frees a slot. The limit is never raised and no account is evicted to make room — a map that fills up means users are being signed in without ever being signed out. A refusal at the cap does not persist a signed-out state. The selection is left exactly where it was, so the next launch is unaffected by the refused attempt and the user is never parked in a state they cannot get out of.accountLimitReached stays raised for your UI to read, and switching back into an account that already has an entry works even when the current selection has no live session.
How the refusal reaches you
Every sign-in path is covered, but they cannot all report the same way:
The rejecting calls throw an
Error whose message is exported as ACCOUNT_LIMIT_MESSAGE, so you can recognise the case without matching on a string you do not own:
accountLimitReached, so a single piece of UI can cover all four.
Signing back in to an account you already store always works, even at the limit. The check is keyed on the account id the server resolves, not on the email you typed — so an account whose stored email is out of date, absent (accounts admitted through external auth may have no email), or capitalised differently signs in normally. This is also why email sign-in is checked after the network call rather than before it: the alternative would lock people out of their own accounts.
Why OAuth cannot throw
handleOAuthRedirect — the synchronous core both @sublay/react-js and @sublay/expo drive their OAuth return through — takes the tokens straight from the redirect. (On the web, handleOAuthCallback parses the callback page’s URL and hands off to it; on Expo the redirect is resolved inline and calls it directly, so that package’s handleOAuthCallback is only a no-op compatibility shim.) It returns as soon as the redirect’s tokens are read — before your app knows whose they are — and on the web the page that called initiateOAuth no longer exists by then. There is no promise left to reject, so both OAuth paths report the limit through accountLimitReached instead. The hook’s own error string stays reserved for failures the provider reported.
What is left behind
No session, ever: a refused sign-in really did create one on the server, and the SDK signs it back out immediately. Nothing in the map either — the stored accounts are untouched and the active account never points at an account that is not stored. One exception, and it is a user record rather than a session. A sign-up refused before the request creates nothing at all. A sign-up whose fifth slot filled while the request was in flight is refused after it, so the account exists on the server: its session is destroyed, but the user record stays and the email is taken. Retrying that email after freeing a slot fails as “already in use” — sign in with it instead. What changes locally depends on the path:- Sign-up, email sign-in and external verification are refused before the SDK touches your session, so whatever account was active stays signed in.
- OAuth cannot be: the redirect hands over tokens before identity is known, so the previous session is already gone by the time the limit is discovered. The selection is left exactly as it was — nothing writes
activeAccountIdon this path — while the session behind it is not re-established. So whichever account the map already named stays named, the app renders signed-out with that account selected, and the user signs in again from the picker. This holds whether or not the flow was started fromaddAccount(), because that call leaves the selection alone too. If nothing was selected before the flow, nothing is selected after it; the refusal neither creates a selection nor clears one.
Three signals for the account cap
Three related but distinct values answer three different questions. None replaces another:
Use
canAddAccount to keep the user from starting a sign-in that cannot succeed, and accountLimitReached to cover the sign-ins that start anyway — a direct sign-in that never went through addAccount(), an OAuth return, or a map that filled in another tab while the request was in flight.
(accounts: Record<string, AccountEntry>, userId?: string | null) => boolean
true when admitting userId into accounts would exceed the 5-account
limit. Pure and read-only — it takes the raw map (from selectAccounts, or
from your own store in integration mode) and
computes; it dispatches nothing.An id already in the map is never an admission, so this returns false
for it even at the cap. That is the whole reason it exists alongside
canAddAccount: re-authenticating an account this device already stores has
to work at the limit, or a user with five accounts could never sign back into
any of them. canAddAccount cannot express that — it knows no id.Omit userId for the sign-up case, where the answer is unconditional: a
sign-up creates a person who by definition is not in the map.When accountLimitReached clears
It clears on the next successful admission and on any removal — but not synchronously. The clear rides the effect that records a successful admission into the account map, so it lands one render after the call that triggered it resolves.
That makes it correct to render and wrong to sample:
handleOAuthRedirect dispatches its completion thunk without one and returns before the cap has been evaluated. So an eager read can observe a stale value from either side.
Re-authenticating into an account the device already stores is one example worth naming, because it looks exempt: that path never trips the cap check, so nothing sets the flag on it — yet a stale true from an earlier refusal survives until that re-auth’s admission lands, one render later. Rendering from the flag gets the settled value in every one of these cases; sampling it gets whichever moment you happened to land in.
Controlling SDK Error Logging
The SDK logs handled failures withconsole.error. Apps whose crash reporters treat console.error as a signal — or that simply want a quieter console — can change the level:
Composing Your Own Account UI
If the built-in hooks do not fit, the transition primitives are exported directly:
The raw
setActiveAccount, upsertAccount and removeAccount reducers are deliberately not exported. They only mutate local state: dispatching setActiveAccount can point the active id at an account that is not in the map, and dispatching removeAccount drops an account without signing it out server-side. Use activateStoredAccount and the useRemoveAccount hook instead — they perform the whole flow.
accountNeedsReauth(entry), accountNeedsPushRebind(entry) and isAccountPushEnabled(entry) are exported for reading those fields off a raw AccountEntry, since absent and false do not always mean the same thing there. isAccountPushEnabled is reported state — the value a per-account switch renders as checked, with an absent preference reading as enabled. It is not the rule the SDK uses to decide whether to create a binding; that one requires an explicit opt-in and is internal, so no binding is ever created for an account that never asked.
One Project Per App
One Sublay project per app is the supported shape. Account storage, the auth gate and the credential slot are one set of module-level singletons, so a second provider mounted for a different project id does not get its own world — it takes over the shared one, and the non-last project’s account switching stops working while a rotated refresh token can be written under the wrong project’s key, locking that account out permanently. Rather than let that happen quietly, mounting a second provider for a different project throws immediately, with a message naming both project ids. Remounting the same project — a hot reload, a provider that unmounts and mounts again, two providers for one project — is fine and is not affected. If you need to talk to two projects, run two apps, or drive the second project through@sublay/js or your own backend rather than mounting a second provider.

