# Save and publish

> A button that shows work in progress, then a success mark only once the request has actually resolved, and a toast with a retry when it fails. Built from Button, StatusMorph and Toast.

- Docs: https://fancy-ui.rama.app/docs/recipes/save-publish

## Components

- [Button](https://fancy-ui.rama.app/docs/components/button.md)
- [StatusMorph](https://fancy-ui.rama.app/docs/components/status-morph.md)
- [Toast](https://fancy-ui.rama.app/docs/components/toast.md)

## Steps

1. Guard the handler so a second activation while the request runs is ignored, and leave the busy state to StatusMorph in the button's end slot. Setting the Button `loading` as well would draw two spinners at once.
2. Drive StatusMorph from the same status: `loading` while pending, `success` after the promise resolves, `error` after it rejects. Never set `success` before the request settles.
3. On failure, raise an `error` toast with a `Retry` action. The action does not close the toast by itself, so dismiss it from the handler and run the request again.
4. Let StatusMorph return to idle on its own (`resetAfter`); the button is ready for the next save without any extra state.

## Pitfalls

- Showing success optimistically: if the request then fails, the interface has already told the user the work is saved.
- Disabling the button while loading: it drops focus and hides the busy state from assistive technology. Keep it enabled and guard the handler instead.
- Leaving the error toast to time out: it carries the only way to retry, so it should stay until the user acts or closes it.

## Svelte

```svelte
<script lang="ts">
	import { Button } from "fancy-ui-svelte";
	import { StatusMorph, type StatusMorphProps } from "fancy-ui-svelte";
	import { Toaster, toast, dismissToast } from "fancy-ui-svelte";

	// Stand-in for your request: resolves after a short delay, or rejects while
	// "fail the next save" is ticked.
	let failNext = $state(false);
	function saveDraft(): Promise<void> {
		return new Promise((resolve, reject) => {
			setTimeout(() => (failNext ? reject(new Error("offline")) : resolve()), 1200);
		});
	}

	let status = $state<NonNullable<StatusMorphProps["state"]>>("idle");

	async function publish() {
		if (status === "loading") return;
		status = "loading";
		try {
			await saveDraft();
			// Success is only ever shown once the request has resolved.
			status = "success";
		} catch {
			status = "error";
			const id = toast({
				title: "Could not publish",
				description: "Check your connection and try again.",
				variant: "error",
				action: {
					label: "Retry",
					onClick: () => {
						dismissToast(id);
						void publish();
					},
				},
			});
		}
	}
</script>

<Toaster />
<div class="flex flex-wrap items-center gap-4">
	<Button onclick={publish}>
		Publish
		{#snippet iconEnd()}
			<StatusMorph bind:state={status} />
		{/snippet}
	</Button>
	<label class="text-muted-foreground flex items-center gap-2 text-sm">
		<input type="checkbox" bind:checked={failNext} />
		Fail the next save
	</label>
</div>
```

## React

```tsx
import { useRef, useState } from "react";
import { Button, StatusMorph, Toaster, dismissToast, toast } from "fancy-ui-react";
import type { StatusMorphState } from "fancy-ui-react";

// Stand-in for your request: resolves after a short delay, or rejects when asked to.
function saveDraft(fail: boolean): Promise<void> {
	return new Promise((resolve, reject) => {
		setTimeout(() => (fail ? reject(new Error("offline")) : resolve()), 1200);
	});
}

export function SavePublishExample() {
	const [failNext, setFailNext] = useState(false);
	const [status, setStatus] = useState<StatusMorphState>("idle");
	// The Retry action outlives the render that created it, so anything it reads has to live in a
	// ref: a closed-over `failNext` or `status` would be the value from the first click.
	const failNextRef = useRef(failNext);
	failNextRef.current = failNext;
	const savingRef = useRef(false);

	async function publish() {
		if (savingRef.current) return;
		savingRef.current = true;
		setStatus("loading");
		try {
			await saveDraft(failNextRef.current);
			// Success is only ever shown once the request has resolved.
			setStatus("success");
		} catch {
			setStatus("error");
			const id = toast({
				title: "Could not publish",
				description: "Check your connection and try again.",
				variant: "error",
				action: {
					label: "Retry",
					onClick: () => {
						dismissToast(id);
						void publish();
					},
				},
			});
		} finally {
			savingRef.current = false;
		}
	}

	return (
		<>
			<Toaster />
			<div className="flex flex-wrap items-center gap-4">
				<Button
					onClick={publish}
					iconEnd={<StatusMorph state={status} onStateChange={setStatus} />}
				>
					Publish
				</Button>
				<label className="text-muted-foreground flex items-center gap-2 text-sm">
					<input
						type="checkbox"
						checked={failNext}
						onChange={(event) => setFailNext(event.target.checked)}
					/>
					Fail the next save
				</label>
			</div>
		</>
	);
}
```

## Vue

```vue
<script setup lang="ts">
import { computed, ref } from "vue";
import { Button, StatusMorph, Toaster, dismissToast, toast } from "fancy-ui-vue";
import type { StatusMorphState } from "fancy-ui-vue";

// Stand-in for your request: resolves after a short delay, or rejects while
// "fail the next save" is ticked.
const failNext = ref(false);
function saveDraft(): Promise<void> {
	return new Promise((resolve, reject) => {
		setTimeout(() => (failNext.value ? reject(new Error("offline")) : resolve()), 1200);
	});
}

const status = ref<StatusMorphState>("idle");
const saving = computed(() => status.value === "loading");

async function publish() {
	if (saving.value) return;
	status.value = "loading";
	try {
		await saveDraft();
		// Success is only ever shown once the request has resolved.
		status.value = "success";
	} catch {
		status.value = "error";
		const id = toast({
			title: "Could not publish",
			description: "Check your connection and try again.",
			variant: "error",
			action: {
				label: "Retry",
				onClick: () => {
					dismissToast(id);
					void publish();
				},
			},
		});
	}
}
</script>

<template>
	<Toaster />
	<div class="flex flex-wrap items-center gap-4">
		<Button @click="publish">
			Publish
			<template #iconEnd>
				<StatusMorph v-model:state="status" />
			</template>
		</Button>
		<label class="text-muted-foreground flex items-center gap-2 text-sm">
			<input v-model="failNext" type="checkbox" />
			Fail the next save
		</label>
	</div>
</template>
```
