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.
Solutions
- Make the callback synchronous: remove async and precompute async work outside the hook
- Perform async pre/post processing in your own code before/after the query instead of inside the hook
- 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
- Never mark lifecycle callbacks as async
- Precompute async values (hashes, lookups) before invoking the query
- Return plain values only: { value }, { issues }, or undefined
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
- Invalid afterRead callback result for table "' + tableName…
- Invalid beforeDelete callback result for table "' +…
- Invalid beforeWrite callback result for table "' +…
- Invalid validator result for table "' + tableName + '"
- Cannot call ' + method + '() from a transaction-scoped…
AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27).
Data as JSON: /api/errors/83c6f52dc9518bb7.
Report an issue: GitHub.
Appendix: 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)