remix-run/remix · error · DataTableValidationError

Invalid ' + callbackName + ' callback result for table "' +

Error message

Invalid ' + callbackName + ' callback result for table "' + tableName + '"

What it means

All synchronous lifecycle callbacks (beforeWrite, validate, afterRead, beforeDelete, afterWrite, afterDelete) must not return a Promise. If a Promise-like value comes back, a DataTableValidationError is thrown naming the callback, because the library executes these hooks synchronously and cannot await them. Marking the callback async or returning an await expression triggers this immediately.

Source

Thrown at packages/data-table/src/lib/database/write-lifecycle.ts:447

    metadata: {
      table: tableName,
      operation,
      ...(source ? { source } : {}),
    },
  })
}

function assertSynchronousCallbackResult(
  tableName: string,
  operation: TableLifecycleOperation,
  callbackName: LifecycleCallbackSource,
  value: unknown,
): void {
  if (!isPromiseLike(value)) {
    return
  }

  throw new DataTableValidationError(
    'Invalid ' + callbackName + ' callback result for table "' + tableName + '"',
    [{ message: callbackName + ' callbacks must be synchronous and cannot return a Promise' }],
    {
      metadata: {
        table: tableName,
        operation,
        source: callbackName,
      },
    },
  )
}

function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
  return (
    (typeof value === 'object' || typeof value === 'function') &&
    value !== null &&
    'then' in value &&
    typeof (value as { then?: unknown }).then === 'function'

View on GitHub (pinned to 9696913134)

Solutions

  1. Make the callback synchronous: remove async and precompute async work outside the hook
  2. Perform async pre/post processing in your own code before/after the query instead of inside the hook
  3. Return plain values ({ value } / { issues } / undefined), never Promises

Example fix

// before
beforeWrite: async (values) => {
  return { value: { ...values, password: await hash(values.password) } }
}

// after
beforeWrite: (values) => {
  return { value: { ...values, password: precomputedHash } }
}
Defensive patterns

Strategy: type-guard

Type guard

function isPromiseLike(value: unknown): value is Promise<unknown> {
  return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function'
}

Try / catch

try {
  await table.create({ values })
} catch (error) {
  if (error instanceof DataTableValidationError && /must be synchronous/.test(error.message)) {
    // make the hook sync; move async work outside
  } else throw error
}

Prevention

When it happens

Trigger: Declaring a lifecycle hook as async (values) => {...}; returning a Promise from a helper inside the hook (e.g. returning bcrypt.hash(...) instead of awaiting it); returning this query inside afterRead.

Common situations: Adding await-based logic (hashing, lookups, fetch calls) to hooks that must stay synchronous; refactoring a hook to async during a feature addition; forgetting that afterRead runs per-row inside a sync loop.

Related errors


AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27). Data as JSON: /api/errors/83c6f52dc9518bb7. Report an issue: GitHub.