# Saving state

> [llms.txt](https://zriz.dev/llms.txt)

Keep the crop for each photo in local storage and bring it back on the next visit.

The page at https\://zriz.dev/docs/saving-state runs this cropper: a 3:2 photo, the straighten dial with rotate and flip, and open, reset and download in the corners. The crop is kept in local storage.

## Read the saved crop before the cropper mounts

Each photo gets its own key, built from an `id` you pass, such as the photo's id in your database. `parse` turns the stored string back into a crop, or returns `null` when there is none. [Value](https://zriz.dev/docs/value.md) shows what it does with broken input.

`parse` and `serialize` come from `@zriz/core`, which [quick start](https://zriz.dev/docs/quick-start.md) installs.

`Root` reads `defaultValue` once, when it mounts. The component renders nothing until it has read storage for this `id`. That empty render unmounts the old cropper, so each photo starts fresh.

Storage can refuse a read, such as in a sandboxed iframe. `read` treats that as no saved crop, so the cropper still opens.

```tsx
const read = (key: string) => {
  try {
    return localStorage.getItem(key);
  } catch {
    return null;
  }
};

const key = `zriz-crop:${id}`;

const [saved, setSaved] = useState<Saved>();

useEffect(() => {
  setSaved({ id, value: parse(read(key)) });
}, [id, key]);

if (saved?.id !== id) return null;
```

## Start from it and save each commit

`serialize` writes a JSON string with a version field and stores the crop as fractions of the photo, so it fits any viewport size. To keep it in a database instead, send the same string with `fetch`.

`Reset` goes back to `defaultValue`, which is now the saved crop. To keep `Reset` on the whole photo, pass `saved.value ?? undefined` as `value` instead. It stays the same object, so it moves the crop only once, see [value](https://zriz.dev/docs/value.md).

The recipe skips `coverage` commits. That is zriz fitting the crop to a new photo, and nobody chose it, so it must not replace the saved crop. A full quota throws on write. Without the `try`, zriz rethrows that as an uncaught error, so the `try` keeps your error reporting quiet and the crop goes on working unsaved.

```tsx
<Cropper.Root
  src={src}
  defaultValue={saved.value ?? undefined}
  onValueCommit={(value, details) => {
    if (details.reason === "coverage") return;

    try {
      localStorage.setItem(key, serialize(value));
    } catch (error) {
      console.warn("the crop was not saved", error);
    }
  }}
>
  <Cropper.Viewport style={{ maxWidth: 640, height: 480 }}>
    <Cropper.Image />
    <Cropper.Mask />
    <Cropper.Area aria-label="Crop area" />
  </Cropper.Viewport>
</Cropper.Root>
```

## The whole file

`SavedCrop` takes a photo URL and an id to store its crop under:

```tsx
"use client";

import { parse, serialize } from "@zriz/core";
import type { CropValue } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useEffect, useState } from "react";

type Props = { id: string; src: string };
type Saved = { id: string; value: CropValue | null };

const read = (key: string) => {
  try {
    return localStorage.getItem(key);
  } catch {
    return null;
  }
};

export const SavedCrop = ({ id, src }: Props) => {
  const key = `zriz-crop:${id}`;
  const [saved, setSaved] = useState<Saved>();

  useEffect(() => {
    setSaved({ id, value: parse(read(key)) });
  }, [id, key]);

  if (saved?.id !== id) return null;

  return (
    <Cropper.Root
      src={src}
      defaultValue={saved.value ?? undefined}
      onValueCommit={(value, details) => {
        if (details.reason === "coverage") return;

        try {
          localStorage.setItem(key, serialize(value));
        } catch (error) {
          console.warn("the crop was not saved", error);
        }
      }}
    >
      <Cropper.Viewport style={{ maxWidth: 640, height: 480 }}>
        <Cropper.Image />
        <Cropper.Mask />
        <Cropper.Area aria-label="Crop area" />
      </Cropper.Viewport>
    </Cropper.Root>
  );
};
```

The demo above saves its crop in this browser under `zriz-docs:saving-state:` and an id for the photo. Change the crop and reload the page, and it comes back. The demo never renders on the server, so it reads storage as it mounts and skips the empty render.

It adds the dial, rotate, flip, handles, a grid and reset, shows the angle to one decimal and uses the styles from the zriz site.
