---
product: "AG Studio"
title: "Undo & Redo"
description: "Studio records each change made to a report, and can step back and forward through them."
framework: react
version: "3.0.0"
related:
    - title: "Modes & Layout"
      url: "https://www.ag-grid.com/studio/react/modes-layout/"
    - title: "Theming"
      url: "https://www.ag-grid.com/studio/react/theming/"
    - title: "Theme Builder"
      url: "https://www.ag-grid.com/studio/react/theme-builder/"
    - title: "Localisation"
      url: "https://www.ag-grid.com/studio/react/localisation/"
    - title: "State"
      url: "https://www.ag-grid.com/studio/react/state/"
    - title: "Exporting"
      url: "https://www.ag-grid.com/studio/react/exporting/"
    - title: "Figma Design System"
      url: "https://www.ag-grid.com/studio/react/figma-design-system/"
llms: "https://www.ag-grid.com/studio/llms.txt"
---

# Undo & Redo

Studio records each change made to a report, and can step back and forward through them.

Undo and redo cover the durable document: widgets, layout, filters and schema. View state such as the selected page and the current selection is left where it is, then brought back into view for the restored change.

#### Undo & Redo

```tsx
"use client";

import type {
  AgFieldDefinition,
  AgReportHistory,
  AgReportState,
  AgStudioMode,
} from "ag-studio";
import { enableStudioDevValidations } from "ag-studio";
import type { AgStudioRef } from "ag-studio-react";
import { AgStudio } from "ag-studio-react";
import React, {
  StrictMode,
  useCallback,
  useMemo,
  useRef,
  useState,
} from "react";
import { createRoot } from "react-dom/client";

if (process.env.NODE_ENV !== "production") {
  // Enable extended validations only for development
  enableStudioDevValidations();
}

const INITIAL_STATE: AgReportState = {
  selectedPageId: "main",
  pages: [
    {
      id: "main",
      widgets: {
        "kpi-revenue": {
          type: "value",
          dataMapping: { value: [{ id: "sales.revenue", aggregation: "sum" }] },
          format: { caption: { enabled: true, text: "Total Revenue" } },
        },
        "by-region": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "sales.region" }],
            valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
          },
          format: { title: { enabled: true, text: "Revenue by Region" } },
        },
        "sales-grid": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "sales.region" },
              { id: "sales.product" },
              { id: "sales.revenue", aggregation: "sum" },
              { id: "sales.units", aggregation: "sum" },
            ],
          },
          format: { title: { enabled: true, text: "Sales" } },
        },
      },
      widgetLayout: {
        "kpi-revenue": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 6 },
        "by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
        "sales-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
      },
    },
  ],
};

const SALES_DATA = [
  { region: "North", product: "Widget", revenue: 4200, units: 120 },
  { region: "North", product: "Gadget", revenue: 3100, units: 80 },
  { region: "South", product: "Widget", revenue: 2600, units: 70 },
  { region: "South", product: "Gadget", revenue: 5400, units: 150 },
  { region: "East", product: "Widget", revenue: 1800, units: 45 },
  { region: "East", product: "Gadget", revenue: 3900, units: 110 },
  { region: "West", product: "Widget", revenue: 4700, units: 130 },
  { region: "West", product: "Gadget", revenue: 2200, units: 60 },
];

const SALES_FIELDS: AgFieldDefinition[] = [
  { id: "region", name: "Region", format: "textFormat" },
  { id: "product", name: "Product", format: "textFormat" },
  { id: "revenue", name: "Revenue", format: "currencyFormat" },
  { id: "units", name: "Units", format: "integerFormat" },
];

const INITIAL_WIDGET_IDS = ["kpi-revenue", "by-region", "sales-grid"];

function selectedPage(state: AgReportState) {
  return state.pages.find((page) => page.id === state.selectedPageId)!;
}

// Read the added charts back from live state, so add and remove stay in step with undo and redo
// rather than tracking a private list that drifts once state is restored.
function addedChartIds(state: AgReportState): string[] {
  const widgets = selectedPage(state).widgets ?? {};
  return Object.keys(widgets).filter((id) => !INITIAL_WIDGET_IDS.includes(id));
}

function describeNext(history: AgReportHistory): string {
  const nextUndo = history.undo.at(-1);
  const nextRedo = history.redo.at(-1);
  const undoText = nextUndo
    ? `${nextUndo.label} (${history.undo.length})`
    : "nothing";
  const redoText = nextRedo
    ? `${nextRedo.label} (${history.redo.length})`
    : "nothing";
  return `undo: ${undoText}   redo: ${redoText}`;
}

const StudioExample = () => {
  const studioRef = useRef<AgStudioRef>(null);
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const data = useMemo(
    () => ({
      sources: [
        { id: "sales", name: "Sales", data: SALES_DATA, fields: SALES_FIELDS },
      ],
    }),
    [],
  );
  const [mode, setMode] = useState<AgStudioMode>("edit");
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);
  const [status, setStatus] = useState("");

  const onStateUpdated = useCallback(() => {
    const history = studioRef.current!.api.getHistory();
    setCanUndo(history.undo.length > 0);
    setCanRedo(history.redo.length > 0);
    setStatus(describeNext(history));
  }, []);

  const doUndo = useCallback(() => {
    studioRef.current!.api.undo();
  }, []);

  const doRedo = useCallback(() => {
    studioRef.current!.api.redo();
  }, []);

  const undoAll = useCallback(() => {
    const { api } = studioRef.current!;
    const oldest = api.getHistory().undo[0];
    if (oldest) api.undo(oldest.id);
  }, []);

  const addChart = useCallback(() => {
    const { api } = studioRef.current!;
    const state = api.getState();
    const page = selectedPage(state);
    const added = addedChartIds(state);
    const nextNum =
      added.reduce(
        (max, id) => Math.max(max, Number(id.slice("chart-".length)) || 0),
        0,
      ) + 1;
    const id = `chart-${nextNum}`;
    const yTrack = 38 + (added.length + 1) * 16;

    api.setState({
      ...state,
      pages: state.pages.map((p) =>
        p.id !== page.id
          ? p
          : {
              ...p,
              widgets: {
                ...p.widgets,
                [id]: {
                  type: "bar-chart-grouped",
                  dataMapping: {
                    categoryKey: [{ id: "sales.product" }],
                    valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
                  },
                  format: {
                    title: {
                      enabled: true,
                      text: `Revenue by Product (${id})`,
                    },
                  },
                },
              },
              widgetLayout: {
                ...p.widgetLayout,
                [id]: { xTrack: 0, yTrack, xSpan: 24, ySpan: 16 },
              },
            },
      ),
    });
  }, []);

  const removeLast = useCallback(() => {
    const { api } = studioRef.current!;
    const state = api.getState();
    const added = addedChartIds(state);
    const id = added[added.length - 1];
    if (id == null) return;

    api.setState({
      ...state,
      pages: state.pages.map((p) => {
        const { [id]: _removedWidget, ...widgets } = p.widgets ?? {};
        const { [id]: _removedLayout, ...widgetLayout } = p.widgetLayout ?? {};
        return { ...p, widgets, widgetLayout };
      }),
    });
  }, []);

  const toggleMode = useCallback(() => {
    setMode((current) => (current === "edit" ? "view" : "edit"));
  }, []);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <div className="example-controls">
          <div className="controls-row">
            <button onClick={doUndo} disabled={!canUndo}>
              Undo
            </button>
            <button onClick={doRedo} disabled={!canRedo}>
              Redo
            </button>
            <button onClick={undoAll} disabled={!canUndo}>
              Undo All
            </button>
            <button onClick={addChart}>Add chart</button>
            <button onClick={removeLast}>Remove last</button>
            <button onClick={toggleMode}>
              {mode === "edit" ? "Switch to View Mode" : "Switch to Edit Mode"}
            </button>
            <span className="history-status">{status}</span>
          </div>
        </div>

        <AgStudio
          ref={studioRef}
          style={studioStyle}
          className="my-studio-container"
          data={data}
          initialState={INITIAL_STATE}
          mode={mode}
          onStateUpdated={onStateUpdated}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <StudioExample />
  </StrictMode>,
);
```

[Live example: Undo & Redo](https://www.ag-grid.com/studio/examples/undo-redo/undo-redo/reactFunctionalTs/)

## Keyboard Shortcuts

While focus is inside Studio:

- `⌃ Ctrl`/`⌘ Cmd` + `Z` - Undo the last change.
- `⌃ Ctrl`/`⌘ Cmd` + `⇧ Shift` + `Z` - Redo.

The shortcuts are bound to Studio's own element, so they work only while focus is inside it. Studio listens nowhere else: with focus in one of your application's inputs or controls, the key press is yours, and a text field inside Studio keeps the shortcut for its own editing history. See [Keyboard Shortcuts](https://www.ag-grid.com/studio/react/keyboard-shortcuts/) for Studio's other shortcuts.

### Undoing From Outside Studio

To undo while focus is elsewhere in your application, bind the shortcut yourself and call `undo()` or `redo()`:

```
document.addEventListener('keydown', (event) => {
    if (!(event.ctrlKey || event.metaKey) || event.code !== 'KeyZ') return;

    event.preventDefault();
    if (event.shiftKey) {
        api.redo();
    } else {
        api.undo();
    }
});
```

How far that binding reaches, and which of your own components it should leave alone, is yours to decide. Studio makes no assumptions about the page around it.

> **Note**
>
> A binding on the document also fires for a press made inside Studio, because Studio does not stop the event propagating. Suppress the shortcut in Studio, as below, so one press does not undo twice.

### Suppressing the Shortcuts

`suppressKeyboard` stops Studio handling a shortcut, leaving the key press for the application to bind:

```jsx
const suppressKeyboard = {
    undo: true,
    redo: true,
};

<AgStudio suppressKeyboard={suppressKeyboard} />
```

Each is independent, so `{ redo: true }` keeps undo on `⌃ Ctrl`/`⌘ Cmd` + `Z`. A suppressed shortcut is left untouched rather than swallowed, so a handler of your own still receives the event.

## Undoing and Redoing in Code

`undo()` and `redo()` each step one change, and do nothing at the end of their stack:

```
api.undo();
api.redo();
```

Making a new change discards whatever was waiting to be redone.

## Reading the History

Studio ships no undo controls of its own, so an application that wants them builds them from `getHistory()`. It returns the live state alongside the changes each action would step through:

```
const { undo, redo } = api.getHistory();
undoButton.disabled = undo.length === 0;
redoButton.disabled = redo.length === 0;
```

Refresh the controls from the `stateUpdated` event, which fires after every committed change - undo and redo included - and also when the history is discarded, whether or not the live state itself changed. A host persisting on this event sees one write per `clearHistory()` call, and one per mode switch once it opts into `history.onModeChange: 'clear'`.

The last entry of each stack is the one that action takes next, and its `label` names the change. Labels come from the locale, so they follow the configured [language](https://www.ag-grid.com/studio/react/localisation/):

```
const next = api.getHistory().undo.at(-1);
undoButton.title = next ? `Undo ${next.label}` : 'Nothing to undo';
```

Each entry also carries an `id`. Passing one to `undo()` or `redo()` steps through every change up to that entry as a single step, which is what a history list needs:

```
const { undo } = api.getHistory();
api.undo(undo[0].id); // back to the start of the history
```

> **Note**
>
> The history holds the last 100 changes by default, and is cleared when Studio loads `initialState`. A later `setState()` is recorded as a change like any other, so it can be undone. It can also be cleared explicitly with `api.clearHistory()`, or automatically on a mode change with `history.onModeChange: 'clear'`, both covered below.

## Limiting the History

Each entry holds the whole state either side of its change, so a long history on a large dashboard costs memory. Set `history.maxEntries` to trade how far back a user can undo against what that retention costs:

```jsx
const history = {
    maxEntries: 20,
};

<AgStudio history={history} />
```

Once the history holds that many changes, recording a new one discards the oldest, which can no longer be undone. Undone changes do not add to the count: undoing moves a change from the undo side to the redo side rather than creating another one, so the two stacks together never exceed the limit.

A value below one keeps no history, leaving `undo()` and `redo()` with nothing to do. Changes still apply as normal; only the ability to reverse them is given up.

## Clearing the History

Call `clearHistory()` to discard the undo and redo stacks without touching the live state:

```
api.clearHistory();
```

`getHistory()` then returns empty stacks, and `undo()`/`redo()` are no-ops until the next change. The live state is untouched, so the changes themselves remain - only the ability to step back over them goes.

Set `history.onModeChange` to have this happen automatically whenever `mode` changes:

```jsx
const history = {
    onModeChange: 'clear',
};

<AgStudio history={history} />
```

The default, `'preserve'`, carries the history across a mode switch. `'clear'` discards it on a switch in either direction, so a change made while in view mode (possible when filter editing is enabled there) is discarded too on the way back to edit mode. Mode does not otherwise gate undo or redo - both work in either mode.

Add a chart or two below, then clear the history explicitly or switch mode with `onModeChange` set to `'clear'`. The counts show both stacks emptying while the charts stay where they are:

#### Clearing the History

```tsx
"use client";

import React, {
  useCallback,
  useMemo,
  useRef,
  useState,
  StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgStudio, AgStudioRef } from "ag-studio-react";
import {
  AgDataEngine,
  AgDataSourcesDefinition,
  AgFieldDefinition,
  AgHistoryOptions,
  AgReportState,
  AgStudioApi,
  AgStudioMode,
  AgStudioProperties,
  enableStudioDevValidations,
} from "ag-studio";

if (process.env.NODE_ENV !== "production") {
  // Enable extended validations only for development
  enableStudioDevValidations();
}

const salesData = [
  { region: "North", product: "Widget", revenue: 4200 },
  { region: "North", product: "Gadget", revenue: 3100 },
  { region: "South", product: "Widget", revenue: 2600 },
  { region: "South", product: "Gadget", revenue: 5400 },
  { region: "East", product: "Widget", revenue: 1800 },
  { region: "East", product: "Gadget", revenue: 3900 },
  { region: "West", product: "Widget", revenue: 4700 },
  { region: "West", product: "Gadget", revenue: 2200 },
];

const salesFields: AgFieldDefinition[] = [
  { id: "region", name: "Region", format: "textFormat" },
  { id: "product", name: "Product", format: "textFormat" },
  { id: "revenue", name: "Revenue", format: "currencyFormat" },
];

const INITIAL_WIDGET_IDS = ["by-region"];

// Held here rather than read back through `getProperty('history')`: a framework wrapper may model
// the property as its own component state, which does not settle until after this handler returns.
let onModeChange: "preserve" | "clear" = "preserve";

function selectedPage(state: AgReportState) {
  return state.pages.find((page) => page.id === state.selectedPageId)!;
}

// Read the added charts back from live state, so the count stays in step with undo and redo rather
// than tracking a private list that drifts once state is restored.
const addedChartIds: (state: AgReportState) => string[] = (
  state: AgReportState,
) => {
  const widgets = selectedPage(state).widgets ?? {};
  return Object.keys(widgets).filter((id) => !INITIAL_WIDGET_IDS.includes(id));
};

const StudioExample = () => {
  const studioRef = useRef<AgStudioRef>(null);
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const [data, setData] = useState<AgDataSource>({
    sources: [
      { id: "sales", name: "Sales", data: salesData, fields: salesFields },
    ],
  });
  const initialState = useMemo<AgReportState>(() => {
    return {
      selectedPageId: "main",
      pages: [
        {
          id: "main",
          widgets: {
            "by-region": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "sales.region" }],
                valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
              },
              format: { title: { enabled: true, text: "Revenue by Region" } },
            },
          },
          widgetLayout: {
            "by-region": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 18 },
          },
        },
      ],
    };
  }, []);
  const history = useMemo<AgHistoryOptions>(() => {
    return { onModeChange: "preserve" };
  }, []);

  const onStateUpdated = useCallback(() => {
    refreshControls();
  }, []);

  const addChart = useCallback(() => {
    const state = studioRef.current!.api.getState();
    const page = selectedPage(state);
    const added = addedChartIds(state);
    // Past the largest suffix in use, not the count: a chart deleted through Studio's own widget
    // menu would otherwise free a number that is still on a surviving chart, and adding would
    // overwrite it instead of adding one.
    const nextNum =
      added.reduce(
        (max, chartId) =>
          Math.max(max, Number(chartId.slice("chart-".length)) || 0),
        0,
      ) + 1;
    const id = `chart-${nextNum}`;
    studioRef.current!.api.setState({
      ...state,
      pages: state.pages.map((p) =>
        p.id !== page.id
          ? p
          : {
              ...p,
              widgets: {
                ...p.widgets,
                [id]: {
                  type: "bar-chart-grouped",
                  dataMapping: {
                    categoryKey: [{ id: "sales.product" }],
                    valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
                  },
                  format: {
                    title: {
                      enabled: true,
                      text: `Revenue by Product (${id})`,
                    },
                  },
                },
              },
              widgetLayout: {
                ...p.widgetLayout,
                [id]: {
                  xTrack: 0,
                  yTrack: 18 + (nextNum - 1) * 14,
                  xSpan: 24,
                  ySpan: 14,
                },
              },
            },
      ),
    });
  }, []);

  const doUndo = useCallback(() => {
    studioRef.current!.api.undo();
  }, []);

  const doRedo = useCallback(() => {
    studioRef.current!.api.redo();
  }, []);

  // The live state is untouched: the charts stay, only the ability to step back over them goes.
  const clearHistory = useCallback(() => {
    studioRef.current!.api.clearHistory();
  }, []);

  const refreshControls = useCallback(() => {
    const { undo, redo } = studioRef.current!.api.getHistory();
    const mode = studioRef.current!.api.getProperty("mode");
    (document.getElementById("undoBtn") as HTMLButtonElement).disabled =
      undo.length === 0;
    (document.getElementById("redoBtn") as HTMLButtonElement).disabled =
      redo.length === 0;
    (document.getElementById("clearBtn") as HTMLButtonElement).disabled =
      undo.length === 0 && redo.length === 0;
    document.getElementById("modeBtn")!.textContent =
      mode === "edit" ? "Switch to View Mode" : "Switch to Edit Mode";
    document.getElementById("onModeChangeBtn")!.textContent =
      `onModeChange: ${onModeChange}`;
    document.getElementById("undoCount")!.textContent = String(undo.length);
    document.getElementById("redoCount")!.textContent = String(redo.length);
  }, [onModeChange]);

  const toggleOnModeChange = useCallback(() => {
    onModeChange = onModeChange === "clear" ? "preserve" : "clear";
    studioRef.current!.api.setProperty("history", {
      onModeChange: onModeChange,
    });
    refreshControls();
  }, [onModeChange, refreshControls]);

  const toggleMode = useCallback(() => {
    const next =
      studioRef.current!.api.getProperty("mode") === "edit" ? "view" : "edit";
    studioRef.current!.api.setProperty("mode", next);
    refreshControls();
  }, [refreshControls]);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <div className="example-controls">
          <div className="controls-row">
            <button onClick={addChart}>Add chart</button>
            <button id="undoBtn" onClick={doUndo}>
              Undo
            </button>
            <button id="redoBtn" onClick={doRedo}>
              Redo
            </button>
            <button id="clearBtn" onClick={clearHistory}>
              Clear history
            </button>
            <button id="onModeChangeBtn" onClick={toggleOnModeChange}>
              onModeChange: preserve
            </button>
            <button id="modeBtn" onClick={toggleMode}>
              Switch to View Mode
            </button>
            <span className="history-status">
              undo: <span id="undoCount">0</span>, redo:{" "}
              <span id="redoCount">0</span>
            </span>
          </div>
        </div>

        <AgStudio
          ref={studioRef}
          style={studioStyle}
          className="my-studio-container"
          data={data}
          initialState={initialState}
          mode={"edit"}
          history={history}
          onStateUpdated={onStateUpdated}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <StudioExample />
  </StrictMode>,
);
```

[Live example: Clearing the History](https://www.ag-grid.com/studio/examples/undo-redo/clear-history/reactFunctionalTs/)

## Undo & Redo API

### Properties

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `suppressKeyboard` | `AgSuppressKeyboard` |  | Keyboard shortcuts Studio should not handle, so an application can bind them itself. Each shortcut is suppressed independently, e.g. `{ redo: true }` keeps undo on ctrl/cmd+Z and leaves ctrl/cmd+shift+Z to the application. |

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `history` | `AgHistoryOptions` |  | How Studio's undo and redo history behaves. |

### API Methods

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `undo` | `Function` |  | Undo the last change to the durable document state (widgets, layout, filters, schema). View state such as the selected page or selection is left as it is, then brought back into view for the restored change. No-op when there is nothing to undo (`getHistory().undo` is empty). Pass a `getHistory().undo` entry's `id` to undo every change back through that entry in a single step; an unknown id is a no-op. |
| `redo` | `Function` |  | Redo the change most recently undone. No-op when there is nothing to redo (`getHistory().redo` is empty). Making a fresh change discards the redo branch. Pass a `getHistory().redo` entry's `id` to redo every change forward through that entry in a single step; an unknown id is a no-op. |
| `getHistory` | `Function` |  | The editing history: the live state plus what `undo()` and `redo()` would step through, each entry labelled and timestamped. Drives control enablement (an empty `undo` stack means there is nothing to undo) and a history list. History does not survive loading new state. |
| `clearHistory` | `Function` |  | Discard the undo and redo history, so `getHistory()` returns empty stacks and `undo()`/`redo()` become no-ops until the next change. The live state is untouched. |
