DEV Community

Kiara
Kiara

Posted on

Trying to Be Too Careful: Catching the Same Error Three Times

One day I was wiring Supabase auth for a login page in one of my personal projects. I forced an error for manual testing — and got two error messages. That happened because the provider stored a message for the UI and the form still had its own failure path for the same rejection — so the same sign-in error was rendered twice.

No news. I’d seen that before. But for the first time, I actually stopped.

A login page is the most basic thing any developer eventually ships. I write it on autopilot now — and AI autocomplete is right there in the cockpit, cheerfully suggesting another try/catch. I’d been quietly replicating some bad habits with it.

Here’s what I had:

  • a service
  • a provider
  • a form
  • a lot of try/catch

Here’s what I didn’t have:

a contract for errors.

The autopilot try/catch

Most of us look at code that might fail and immediately wrap it in try/catch, throw something, and assume “the UI will handle it.”

In my auth flow, that looked like this:

Servicetry/catch around the API call, throw on failure

(fair: something has to detect the external failure)

Provider — another try/catch, set state, throw again

(autopilot / copy-paste: “I should handle errors here too”)

Form — same energy again

(just in case)

Three layers touching the same error.

But who owns it? Are they throwing the same thing? What is the UI supposed to do with the same failure bouncing around three times?

That’s not carefulness. That’s a responsibility problem.

Before: everyone “handles” it

// provider — stores the message… and also wraps/rethrows
const signUserIn = async (email: string, password: string) => {
  try {
    await signIn(email, password)
  } catch (error) {
    setError('Error signing in: ' + (error as Error).message)
    throw new Error('Error signing in: ' + (error as Error).message, { cause: error })
  }
}
Enter fullscreen mode Exit fullscreen mode
// form — touches the same failure again
const onSubmit = (data: LoginFormData) => {
  signIn(data.email, data.password)
    .then(() => navigate('/admin'))
    .catch((error) => {
      // second path for the same error (toast, local state, another throw…)
      throw new Error('Error signing in: ' + error.message, { cause: error })
    })
}
Enter fullscreen mode Exit fullscreen mode

Same failure. Two owners. Messy contract.

Detect → Store → Display

If an error occurs, what must happen before the UI shows it?

  1. Detect it
  2. Store it (so the UI can read a stable message)
  3. Display it

I started calling that split Detect → Store → Display:

Step Layer
Detect service
Store context / provider
Display UI

Responsibilities separated. Then the contract:

Pick one way the service signals failure — and stick to it.

throw or a result object ({ data, error }). Both work. Mixing both means whoever calls the service never knows what to check.

I went with throw. One path. Clear for callers.

How it looks now

// service — DETECT
export async function signIn(email: string, password: string): Promise<Session> {
  const { data, error } = await supabase.auth.signInWithPassword({ email, password })
  if (error) throw new Error(error.message, { cause: error })
  if (!data.session) throw new Error('No session returned')
  return data.session
}
Enter fullscreen mode Exit fullscreen mode
// provider — STORE (+ rethrow so the caller still sees failure)
const signUserIn = async (email: string, password: string) => {
  setError(null)
  try {
    await signIn(email, password)
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Sign in failed')
    throw err // same error — don't wrap it again
  }
}
Enter fullscreen mode Exit fullscreen mode
// UI — DISPLAY
{error && <p role="alert">{error}</p>}
Enter fullscreen mode Exit fullscreen mode
// form — no second error owner; just await success vs failure
const onSubmit = async (data: LoginFormData) => {
  try {
    await signIn(data.email, data.password)
    navigate('/admin')
  } catch {
    // message already stored in context
  }
}
Enter fullscreen mode Exit fullscreen mode

The form doesn’t need to “handle” the error again. It just renders what the provider already stored.

Same rule, elsewhere in the app

Auth was where I noticed the mess — but the useful part is that Detect → Store → Display isn’t an “auth pattern.”

In the same project, registration already talks to Supabase through a service and surfaces failure through hook/UI state (in that flow, React Query helps with the “store” side). Once I named the contract, the question got simpler everywhere:

  • Does the service only detect and throw?
  • Does one state layer own the message?
  • Does the UI only display — without catching and re-throwing the same failure?

I don’t need a different philosophy per feature. I need the same contract so I’m not “extra careful” in three places and clear in none.

What I got out of it

  • a fail path I can actually read
  • layers with a clear job
  • one message, one owner
  • a rule I can reuse on the next form, not only on login

And just like that, I quit being a “way too careful, but not so much” coder — the kind who wraps everything in try/catch and still doesn’t know who owns the failure.

Careful isn’t more catches.

Careful is a contract.

Top comments (0)