DEV Community

Mafy Hidalgo
Mafy Hidalgo

Posted on

How I Built RollbackHQ: An "Undo Button" for AI... My First Real React App

RollbackHQ: An

The idea: what happens when an AI silently changes your numbers?

Companies are starting to let AI systems read and write their business data like forecasts, inventory counts, financial figures. But what happens when the AI hallucinates?

Imagine an inventory AI that misreads a data glitch and changes a reorder quantity from 44 boxes to 4,400. Nobody notices. The company orders millions of pesos of stock nobody needs - and the original number is gone forever, because the AI overwrote it.

That's the problem RollbackHQ solves.

It's a safety dashboard that sits between an AI and the database. Before any AI-driven change is committed:

  1. 📸 It snapshots the original value
  2. đŸ›Ąī¸ It validates the new value against safety rules (changes beyond a % threshold get auto-flagged)
  3. 👤 It routes changes to a human for approval
  4. â†Šī¸ And if something bad slips through — one-click rollback to any known-good version

I call it "undo for your AI."

The Stack

  • React + Vite — the app itself
  • Redux Toolkit — centralized state (five slices: records, changes, snapshots, audit log, settings)
  • React Router — five pages: Dashboard, Snapshots, AI Changes, History, Settings
  • Local Storage — persistence across reloads
  • Cypress — end-to-end tests (which caught a real bug — more on that later!)
  • Plain CSS, responsive down to phone widths

The MVP simulates the AI source and the database in the browser, per my project proposal - the production version would run as middleware with a real backend, which is my plan for the next projects.

Step 1: Design the data model BEFORE writing components

The best advice I got: figure out what your data looks like first, and the components almost write themselves. RollbackHQ has four kinds of data, connected by recordId like a foreign key:

// A record — a business figure we're protecting
{ id: "REC-2041", name: "Q3 Sales Forecast", field: "amount", value: 1200 }

// An AI change — a PROPOSAL, not a write
{
  id: "CHG-002",
  recordId: "REC-1180",
  oldValue: 44,
  newValue: 4400,
  status: "flagged",   // pending | flagged | approved | rejected
  reason: "Change exceeds 500% threshold - likely AI error",
}

// A snapshot — the "before photo", captured automatically
{ id: "SNAP-003", recordId: "REC-1180", version: 1, value: 44, label: "initial" }

// An audit entry — every decision leaves a trace
{ id: "AUD-001", recordId: "REC-2041", action: "approved", detail: "amount: 1200 → 1260" }
Enter fullscreen mode Exit fullscreen mode

The single most important rule in the whole app: a pending or flagged change has ZERO effect on the record. Only a human clicking Approve writes to the data. The AI doesn't get to write — it only gets to propose.

Step 2: Redux Toolkit — the app's brain

Every page needs to see the same data, so all application state lives in one Redux store with a slice per data type. Here's the records slice:

import { createSlice } from "@reduxjs/toolkit";
import { initialRecords } from "../data/mockData";

const recordsSlice = createSlice({
  name: "records",
  initialState: initialRecords,
  reducers: {
    // Used by both Approve and Rollback
    updateRecordValue: (state, action) => {
      const { recordId, newValue } = action.payload;
      const record = state.find((r) => r.id === recordId);
      if (record) {
        record.value = newValue;
      }
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

(Fun fact I learned: mutating state like this is normally forbidden in React, but Redux Toolkit uses Immer under the hood, which secretly builds an immutable copy. It looks like mutation but isn't.)

The payoff came when I built the review screen. Approving a change dispatches three actions - update the record, flip the change status, write an audit entry:

const handleApprove = (change) => {
  // 1. Commit the new value to the record
  dispatch(updateRecordValue({ recordId: change.recordId, newValue: change.newValue }));
  // 2. Mark the change as approved
  dispatch(setChangeStatus({ changeId: change.id, status: "approved" }));
  // 3. Leave a trace in the audit log
  dispatch(addAuditEntry({
    id: `AUD-${Date.now()}`,
    recordId: change.recordId,
    action: "approved",
    detail: `${change.field}: ${change.oldValue} → ${change.newValue}`,
    timestamp: new Date().toISOString(),
  }));
};
Enter fullscreen mode Exit fullscreen mode

...and the Dashboard's "Pending Approval" counter on a completely different page updates instantly, because both pages read the same store. No message passing. That was the moment Redux clicked for me.

The AI Change Review screen — before/after diff with the red flagged banner

Step 3: The rule engine — catching the AI's mistakes

The validation logic lives in a pure function, separate from the UI. It simulates an AI proposing changes — usually small sensible tweaks, but 30% of the time a wild error (10x–100x the real value), because that's what makes the demo interesting:

// 30% chance of a faulty AI output
const isCrazy = Math.random() < 0.3;
let newValue;
if (isCrazy) {
  newValue = record.value * (10 + Math.floor(Math.random() * 90)); // 10x-100x
} else {
  const tweak = 1 + (Math.random() * 0.2 - 0.1); // -10% to +10%
  newValue = Math.round(record.value * tweak);
}

// Rule check: percent change vs the configurable threshold
const percent = Math.abs(((newValue - record.value) / record.value) * 100);
const isFlagged = percent > thresholdPercent;
Enter fullscreen mode Exit fullscreen mode

Crucially, the snapshot of the current value is captured BEFORE the proposal is even evaluated. Protect first, then consider.

Step 4: Persistence in ~20 lines

One of my graded specs was Local Storage. The elegant part: because ALL state lives in Redux, persistence is one subscription on the store — no component changes at all:

// After every dispatch, save the whole state
store.subscribe(() => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(store.getState()));
});

// On startup, load it back (or fall back to mock data)
const loadState = () => {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved === null) return undefined; // undefined = use initial state
  return JSON.parse(saved);
};
Enter fullscreen mode Exit fullscreen mode

Simulate some changes, approve a few, refresh the page, everything survives.

Snapshot History timeline with the Restore buttons

The bug my own tests caught (my favorite part)

For bonus points I added Cypress end-to-end tests - scripts that open a real browser and click through the app like a user. I wrote five tests: dashboard renders, navigation works, simulating adds a change, approving updates the record, and state persists across reloads.

Four passed. One went red.

(uncaught exception) ReferenceError: addAuditEntry is not defined
  at handleSimulate (src/pages/Dashboard.jsx)
Enter fullscreen mode Exit fullscreen mode

Here's the thing: I had manually tested this feature and it "worked." But the crash only happened when a simulated change got FLAGGED which is a 30% random chance. My manual clicks had gotten lucky rolls. The robot clicked once, hit a flagged change, and exposed a missing import I'd forgotten when adding the flag-to-audit-log feature.

The Cypress runner showing 4 green / 1 red with the ReferenceError

The fix was one line. The lesson was bigger: testing shows the presence of bugs, never their absence - but automated tests catch what lucky manual testing walks right past. The bug had even made it into my deployed site. One-line fix, rebuild, redeploy, 5/5 green.

Deploying

npm run build produces an optimized dist folder, and Netlify's drag-and-drop deploy puts it on the public internet in about 20 seconds. One gotcha worth knowing for any React Router app: you need a _redirects file telling the server to serve index.html for every path — otherwise refreshing on /snapshots gives a 404, because the routes only exist in the browser:

/*    /index.html   200
Enter fullscreen mode Exit fullscreen mode

What I learned

  • Design the data model first. Every screen was easier because the state shape was right.
  • Store the minimum, compute the rest. The KPI numbers aren't stored anywhere — they're derived from the changes array on every render, so they can never go stale.
  • Read the error, not just the suggestion. Vite once told me to "try inserting a semicolon" when the actual bug was cconst — a typo. The quoted code in an error message is more truthful than the parser's guess.
  • Terminal commands and file contents are different things. Ask me how I know. 😅

What's next

This won't stay an MVP. My plan for the upcoming backend and full-stack projects is to grow RollbackHQ into the real thing: a Node/Express API with JWT authentication and user roles, the rule engine running server-side with email alerts when anomalies are detected, and a real AI integration replacing the simulator. Same product, three projects, one story.

> Mafy Hidalgo is a full-stack web development student at Uplift Code Camp (FSWBATCH29). RollbackHQ is her Project 4.

Top comments (0)