# Transition hooks (/docs/schema/hooks/transitions)

---
title: Transition hooks
description: A workflow stage move is not a write, so the two hooks around it carry their own context, and only the first of them can stop the move.
kind: guide
package: questpie
---

`transitionStage()` moves a record from one stage to the next without changing
a field on it. `beforeTransition` and `afterTransition` sit around that move.
They live in the same `.hooks({ ... })` block as everything else, but almost
nothing about their context is the same.

## Turning them on

Stage moves are stored as version snapshots, so a workflow needs versioning.

```ts title="src/questpie/server/collections/posts.ts"
.options({ versioning: { workflow: true } })   // stages: draft, published
```

Leave the workflow off and `transitionStage()` throws, so neither hook ever
fires. [Options](/docs/schema/collections/options) covers custom stages and the
moves they allow.

## The pair

```ts title="src/questpie/server/collections/posts.ts"
import { ApiError } from "questpie/errors";

.hooks({
	beforeTransition: ({ data, toStage }) => {
		if (toStage === "published" && !data.publishedAt) {
			throw ApiError.badRequest("Set publishedAt before publishing.");
		}
	},
	afterTransition: async ({ data, toStage, queue }) => {
		await queue.announce.publish(
			{ postId: data.id, stage: toStage },
			{ idempotencyKey: `announce:${data.id}:${toStage}` },
		);
	},
})
```

Both run inside the transaction that writes the snapshot, and both receive the
same object.

| Key                    | What you get                                                    |
| ---------------------- | --------------------------------------------------------------- |
| `data`                 | The record, as it stands before the move.                       |
| `recordId`             | Its id.                                                         |
| `fromStage`, `toStage` | The move.                                                       |
| `scheduledAt`          | Whatever date the caller passed, if they passed one.            |
| `expectedRevision`     | The revision the caller asserted, under optimistic concurrency. |
| `locale`, `accessMode` | As on any hook.                                                 |

Everything else is your app context: `db`, `collections`, `queue`, `session`,
your own services. There is no `operation`, no `original`, and no
`onAfterCommit`, so a non-transactional side effect has no safe home here. Put
it in a durable job instead.

<Callout type="warn" title="Only `beforeTransition` can refuse">
	Throwing from it aborts the move. An error from `afterTransition` is caught
	and logged, and the transition still commits.
</Callout>

## A future date skips both

Pass a `scheduledAt` in the future and QUESTPIE queues the move rather than
running it. That decision is itself a `beforeTransition`, and it runs ahead of
yours, so neither of your hooks fires on that call. They fire when the queued
job performs the move.

## Related

- [Hooks](/docs/schema/hooks) for the write lifecycle.
- [Options](/docs/schema/collections/options) for declaring the stages.
- [Versions and stages](/docs/schema/globals/versions) for the same pair on a
  global.
