ryer.io

Pulling the Auth Context Apart: One Responsibility at a Time

TL;DR

  • loadCredentials was doing credential retrieval, user assembly, and UI state updates all at once.
  • Split it: loadCredentials fetches and stores, initializeUser assembles, UI state moves out entirely.
  • Return null explicitly rather than undefined, so callers can distinguish “no user” from “something broke”.
  • A blanket error handler covering both our endpoints and Auth0 was hiding which system actually failed.
  • Returning null shouldn’t always mean logout — check for a sub before tearing the session down.

A long session restructuring authentication, driven by a simple observation: almost every bug I’ve chased this week traces back to one function doing three jobs.

The function that did everything

loadCredentials was fetching credentials from local and remote sources, assembling the user object, and updating UI state. With all of that in one place there was no clear distinction between a valid credential state and an invalid one — the function could fail in three different ways and they all looked the same from outside.

The split:

  • loadCredentials retrieves valid credentials and stores them locally. That’s it.
  • A separate function assembles user metadata from those credentials.
  • UI state updates move out of both.

I also extracted initializeUser, which had been embedded inside loadCredentials and coupled the two unnecessarily:

1
2
3
4
function initializeUser(credentials) {
  if (!credentials) return null;
  // Assemble and return user object
}

Pulling it out forced me to define its return type honestly, and that set off a chain reaction — everywhere the user object gets consumed had to handle null and decide what null means there. That chain reaction is the actual value of the refactor. The nulls were always possible; they just weren’t visible.

Null, not undefined

Related and deliberate: when credentials are invalid, these functions now return null explicitly rather than falling off the end into undefined.

undefined means “nobody thought about this path”. null means “this path was considered and there’s genuinely nothing here”. Since undefined values propagating into loading states have caused me real bugs this week, making the distinction explicit is worth the small ceremony.

Naming that tells the truth

Small but worth recording: I renamed assembleAuthContextReturn to assembleAuthContext, so the name describes what it produces rather than where the result happens to go.

Error handling near the source

The messier problem was error handling. Auth0 sometimes throws exceptions and sometimes returns null for what is arguably the same condition, and we had a shared error handler covering both our own endpoints and Auth0’s.

That’s misleading. Our endpoints fail for different reasons than Auth0 does, and blanketing them together means the handler can’t respond appropriately to either. Errors need handling as close to the source as possible, where there’s still enough context to know what actually went wrong.

The concrete symptom was in loadCredentials during credential refresh: iOS and Android threw and caught differently, producing unnecessary alerts and spurious logouts. Tracing it back, mismanaged null returns were the cause.

The fix, and this is the one that mattered: returning null should not automatically trigger a logout. I added a check on whether the user data actually contains a sub before routing toward logout. If user !== null the flow proceeds normally; otherwise state updates happen without destroying the session. That alone removed a category of premature logouts.

I also cut redundant try-catch blocks. They were bulky and weren’t catching anything specific — replacing them with targeted handling for known scenarios like missing credentials did more with less code.

Tightening the types

I moved the auth context interfaces off any and into explicit definitions. Ordinary work, but it’s what makes the null handling above enforceable rather than aspirational.

The endpoint that needs splitting

A tangent worth its own ticket: the endpoint distinguishing a registered user from an unregistered one does it by hinging on nonexistent parameters. It’s overstuffed and wants dividing. What we need is a minimal user-data return purely for initialisation — separate from whatever else that endpoint is currently trying to be.

To test the error responses properly I’ll need to simulate user deletion in a test database, and I still owe myself an answer on whether Axios returns errors inside the response or throws them, because the handling differs.

What’s still wrong

refreshAuth is too involved and is a source of timing issues, particularly on iOS. It needs the same treatment as loadCredentials — probably a more serious modularisation than I did today.

There’s an architectural tension underneath all of this that I keep circling. Device storage is a singleton; React is state-driven. Blending a pseudo-state-machine with singleton instances creates synchronisation problems that no amount of local tidying will fix. Centralising the logic rather than sprinkling state management across components is the direction, but it’s a bigger decision than today’s.