Skip to content

State Management

Ghost’s frontend manages all application state through Zustand — a lightweight state management library for React. Instead of one giant global store, Ghost splits its state across 21 focused stores, each responsible for a single domain (flows, sessions, devices, addons, etc.). Each store is a plain JavaScript object containing state fields and action methods — no middleware, no reducers, no boilerplate. The only persistence is localStorage for a handful of UI preferences (theme, pinned items, layout choices).

Think of each store like a filing cabinet drawer. The flow store holds all the network traffic data. The session store holds session information. The addon store holds addon scripts and their log output. Components reach into whichever drawers they need, and when a WebSocket event arrives from the backend, the App.tsx event router drops the update into the right drawer automatically.

#StoreKey StateLive UpdatesCaps
1useFlowStoreflows[], selectedFlowId, selectedFlow, hostStats, appStats, deviceStats, multi-select with Shift+Click rangeflow.created/updated/deleted5,000 per fetch, 100ms batch
2useUIStoreappMode, theme, searchQuery, filters, viewMode, groupMode, slideOverPanel, activePanel, previousPanel, wsStatus, 11 localStorage keysfinding.* (version bump)
3useSessionStoresessions[], activeSessionIdsession.created/updated/deleted
4useProxyStorestatus (running, address, systemProxy)proxy.started/stopped/status
5useAgentStoreconversations[], messages[], isStreaming, plan, runMetrics, optionsSSE streaming (12 event types including options for interactive choices)
6useAddonStoreaddons[], logs[], selectedAddonIdaddon.created/updated/deleted/log500 log entries
7useExtensionStoreconnected, interactions[], tabUrl, everSeenextension.connected/disconnected/interaction/tab_switch50 interactions
8useBreakpointStorerules[], pendingFlows: Mapflow.breakpoint/resumed
9useRulesStorerules[], selectedRuleIdrule.created/updated/deleted
10useInjectionRulesStorerules[], selectedRuleIdinjection_rule.created/updated/deleted
11useDeviceStoredevices[], connecting timeoutdevice.discovered/updated/connected/disconnected/removed120s timeout
12useArtifactStoreartifacts[], optimistic deleteartifact.created/deleted
13useFridaStorestatus, devices/apps/processes/sessions/scripts, consoleLines, batched outputfrida.session.started/stopped, frida.output500 lines/session, 100ms batch
14useAttackerStorerunning, summary, wordlists, completed/total/matched, latestResultsattacker:started/progress/completed/error50 results
15useUpdaterStoreavailable, updateInfo, downloading, progress, installedTauri updater events30s check timeout
16useSetupStorecompleted, phase, certInstalled, certSkipped
17useComparisonStoresessionA/B, comparison, selectedEndpointKey, flow pair loading
18useSettingsStoresettings, validating, validationResult
19useInspectorStorehierarchy, selectedElementId, selectors[], bugReport, screenshot freeze/scrub, interaction mode

Plus two more stores referenced in imports (21 total store files in the stores/ directory).

The flow store is the most complex store, managing the primary data model — all captured HTTP traffic. It handles thousands of flows, multi-select, live filtering, batched updates, and on-demand detail loading.

FieldTypePurpose
flowsFlowDTO[]The visible flow list. Uses lightweight summary DTOs (no bodies or headers — just method, status, host, path, timing, size). Default fetch limit: 5,000 flows.
totalnumberTotal flow count from the backend (may be larger than flows.length if not all are loaded).
loadingbooleanWhether a flow fetch is in progress.
errorstring | nullError message from the last failed operation.
newFlowIdsSet<string>Set of flow IDs that just arrived via WebSocket. Used to trigger a CSS flash animation on new rows in the traffic list. IDs are cleared after the animation completes.
selectedFlowIdstring | nullThe currently selected flow (single-click).
selectedFlowFlowDTO | nullThe full detail DTO for the selected flow (loaded on demand via a separate API call, includes headers and body).
detailLoadingbooleanWhether the detail fetch for the selected flow is in progress.
comparisonFlowIdstring | nullA second flow selected for side-by-side comparison.
comparisonFlowFlowDTO | nullFull detail DTO for the comparison flow.
comparisonPickerOpenbooleanWhether the comparison flow picker dialog is open.
selectedFlowIdsSet<string>Set of flow IDs selected via multi-select (Ctrl+Click). Used for bulk operations like delete or tag.
lastSelectedIndexnumber | nullIndex of the last selected flow. Used for Shift+Click range selection — clicking with Shift held selects all flows between lastSelectedIndex and the clicked index.
hostStatsHostStatDTO[]Aggregated request counts per hostname. Drives the domain navigator in the left sidebar.
appStatsAppStatDTO[]Aggregated request counts per source application. Drives the app navigator.
deviceStatsDeviceStatDTO[]Aggregated request counts per device. Drives the device navigator.

The addFlow and updateFlow actions use an internal batching mechanism to prevent excessive React re-renders during burst traffic. Instead of updating the store on every single WebSocket event (which could fire hundreds of times per second during active browsing), events are collected in a pending buffer and flushed every 100ms in a single state update.

Both functions are implemented as IIFEs (Immediately Invoked Function Expressions) that return closures — they maintain internal pending arrays and timer references in their closure scope. Each has a _cancel() method used by clearFlows() to prevent stale batches from re-inserting deleted flows.

When a flow.created WebSocket event arrives, the flow doesn’t automatically appear in the traffic list. It first passes through matchesClientFilter() — a client-side filter function that checks, in order:

  1. Method filter — case-insensitive match against the HTTP method (GET, POST, etc.)
  2. Status code filter — supports exact codes (404), range wildcards (2xx, 3xx), and comparisons (>399, <500)
  3. Host filter — substring match from structured filter conditions
  4. Content type filter — substring match from structured filter conditions
  5. Domain navigator selection — exact match against the selected domain in the scope panel
  6. Content type panel selection — substring match against the selected content category
  7. App navigator selection — exact match against the selected application
  8. Device navigator selection — matches against device ID or client IP
  9. Free text search — case-insensitive substring search across method, host, path, and URL

Only flows that pass ALL active filters are added to the visible list. This means your live feed always respects whatever you’re currently filtering — if you’re looking at “status:>399”, only error responses appear in real time, not all traffic.

Host, app, and device statistics are refreshed via a debounced callback with a 2-second delay triggered by flow creation or deletion events. This prevents hammering the stats API during burst traffic — if 100 flows arrive in 1 second, the stats refresh fires once, 2 seconds after the last event.

The UI store is the central state hub for everything related to layout, appearance, navigation, and mode switching. It has more state fields than any other store (over 30) and interacts with several other stores.

Mode management: Tracks appMode (QA vs Security), securityMode (web vs mobile), and scanMode (passive, active-safe, active-full). Switching appMode is a heavy operation — it resets all filters, selections, slide-over panels, inspector state, Frida state, sort settings, flow selection, and security center view. This clean slate ensures mode switches start fresh.

Theme: Manages dark/light theme. Switching the theme updates the DOM immediately (adds/removes dark class on the document root) and persists the choice to localStorage.

Layout controls: View mode (list vs waterfall), group mode (none, by host, by path, by tag), panel layout (horizontal vs vertical split), detail layout (stacked vs side-by-side), and scope panel collapse state.

Scope selection: Tracks which domains, apps, devices, and content types the user has selected in the left sidebar. These selections drive the flow list’s client-side filtering.

Pinned items: Users can pin frequently-used domains, apps, and devices to keep them at the top of the navigator lists. Pinned items are persisted to localStorage so they survive app restarts.

Filter presets: Named filter queries that the user has saved (e.g., “API Errors” = status:>399 content_type:json). Stored in localStorage.

Slide-over panel: Tracks which slide-over panel is currently open (or null for closed). Opening a panel sets slideOverPanel to the panel ID string.

Exclusive center panel routing: The UI store manages a single activePanel: string | null slot for panels that replace the traffic view (Attack Planner, Session Compare, Inspector, Frida, Findings). Panel definitions are in stores/panel-registry.ts. openPanel(id) captures a PanelSnapshot (label + restore closure) of the current panel before switching, enabling context-aware back navigation. goBack() restores the previous panel via its closure. closePanel() returns to the traffic view.

Cross-store interactions: The UI store reaches into other stores when needed:

  • setAppMode() calls useFlowStore.getState().setSelectedFlowId(null) and clearSelection() to deselect flows, resets activePanel to null
  • closeFridaPanel() calls useFridaStore.getState().reset() to clean up Frida state
  • openInspector(deviceId) calls openPanel('inspector') then sets the inspector device ID

11 keys are persisted to localStorage — these are the only persistent state in the entire frontend:

KeyWhat It Stores
ghost-app-modeQA or Security mode
ghost-themeDark or Light theme
ghost-pinned-hostsJSON array of pinned domain names
ghost-pinned-appsJSON array of pinned application names
ghost-pinned-devicesJSON array of pinned device IDs
ghost-detail-layoutInspector layout (stacked or side-by-side)
ghost-panel-layoutSplit panel orientation (horizontal or vertical)
ghost-filter-presetsJSON array of saved filter presets
ghost-security-modeSecurity sub-mode (web or mobile)
ghost-scan-modeSecurity scan mode (passive, active-safe, active-full)
ghost-security-sidebar-tabActive tab in security sidebar

No other store uses localStorage or any persistence middleware.

The agent store manages the AI chat conversation using Server-Sent Events (SSE) — not WebSocket. When the user sends a message, the store opens an SSE connection to POST /api/v1/agent/chat and processes streaming events as they arrive. This is different from the WebSocket connection used for traffic events — SSE was chosen for the agent because it supports POST request bodies (to send the user’s message) and has a simpler lifecycle for request-response streaming.

EventWhat It Does
chunkAppends a text token to the current assistant message (the message builds up word by word as tokens stream in)
tool_callCreates a new tool call card in the message (showing the tool name and input parameters). Marked as pending: true until the result arrives.
tool_resultMarks the matching tool call as complete and attaches the result text
plan_createdSets the structured task plan (the agent’s step-by-step execution plan)
plan_revisedUpdates the plan with a revised version (agent adjusted its approach)
plan_completedMarks the plan as completed
step_completedMarks a specific step in the plan as completed
optionsSets options on the current message — renders an OptionsPanel with interactive choice cards and free-text input. The agent loop pauses until the user responds.
steerAcknowledgment that a steer message was received (no UI update)
metricsUpdates the run metrics display (token usage, tool call counts, iteration count). Run Summary is hidden when termination reason is "waiting for user choice".
doneFinalizes the message, assigns permanent IDs, sets activeConversationId, refreshes the conversation list
errorStops streaming and shows the error

The agent store uses two mechanisms to prevent stale data:

  • _abortController — an AbortController that cancels the active SSE stream when the user starts a new conversation, loads a different conversation, or explicitly stops generation
  • _streamGeneration — a monotonic counter that increments on every conversation switch. SSE callbacks check this counter before updating state, ensuring events from a previous conversation don’t corrupt the current one.

The breakpoint store manages flows that are paused mid-flight — waiting for the user to inspect and optionally modify them before they continue to the server (request breakpoints) or to the client (response breakpoints).

The pendingFlows field is a Map keyed by flow ID, where each value is a BreakpointEvent containing:

  • flow_id — the flow being held
  • phase — whether this is a request or response breakpoint
  • method, url, host, path — request identity
  • headers, body — request headers and body (editable)
  • status_code, response_headers, response_body — response data (for response breakpoints, editable)

When a flow.breakpoint WebSocket event arrives, the flow is added to the map and the BreakpointEditor overlay appears. When the user resumes the flow (with or without modifications), the resumeFlow action sends the modifications to the backend, and the flow.resumed WebSocket event removes the flow from the pending map.

The device store tracks all connected mobile devices and simulators/emulators for the inspector feature. It includes a 120-second connecting timeout — if a device stays in the connecting state for 2 minutes (device connection involves starting WDA on iOS or atx-agent on Android, which can be slow), it automatically transitions to an error state with the message “Connection timed out — device may still be starting. Try again.”

The timeout is tracked per-device using a module-level Map<string, timeout>. When a terminal state arrives via WebSocket (connected, disconnected, error), the timeout is cleared.

The inspectDevice action is a convenience method that resolves a traffic-level device key to an inspector device, auto-connects if needed, and opens the inspector panel — all in one click from the device navigator in the scope panel.

The extension store tracks the browser extension’s connection status and captured interactions (clicks, form inputs, navigation, etc.). Interactions are capped at 50 entries — oldest entries are dropped when the cap is reached.

The everSeen flag tracks whether the extension has ever connected during this session. This drives the status bar indicator — it only shows “Extension” after the first connection, to avoid showing a permanently “disconnected” indicator to users who don’t use the extension.

The extension.interaction WebSocket event requires camelCase-to-snake_case mapping because the browser extension sends events in camelCase format but the frontend DTOs use snake_case.

The Frida store manages runtime instrumentation sessions, including device/app selection, script editing, saved scripts, and console output. It’s one of the most complex stores with over 30 state fields and 20+ actions.

Frida scripts can produce console output at very high rates (hundreds of lines per second during hooking). To prevent React from re-rendering on every single line, the store batches console output using a module-level buffer:

  • Lines are collected in a consoleBatch Map (keyed by session key)
  • A flush timer fires every 100ms
  • On flush, all buffered lines are applied to the store in a single state update
  • Each session is capped at 500 console lines (oldest lines are trimmed when the cap is reached)

The batching mechanism is module-level (not part of the Zustand state) so it survives store resets. It uses a _generationId counter to discard stale batches — when the user switches devices or resets the store, the generation ID increments, and any in-flight batches from the previous generation are silently dropped.

Like the agent store, the Frida store uses a _generationId for async race protection. All async actions (device selection, app listing, session management) check the generation ID before applying their results. This prevents stale data from a slow API response from overwriting the state for a newly-selected device.

The attacker store manages the request fuzzer’s lifecycle. The attacker runs asynchronously on the backend — the frontend sends a start request and receives progress updates via WebSocket events.

The latestResults field caps at 50 entries and only keeps “interesting” results (requests that matched at least one match rule). This prevents memory growth during large attacks that might test thousands of payloads.

The comparison store manages session-to-session comparison data (session selection, comparison results, flow pair loading). Routing is handled by the UI store’s activePanel — the comparison view renders when activePanel === 'comparison'. The store includes race protection:

  • _fetchGenId — monotonic counter that prevents stale comparison results from overwriting newer ones
  • _abortController — cancels in-flight comparison API requests when the user changes sessions

Same-session comparison (comparing a session against itself) is explicitly rejected with an error message.

The updater store checks for Ghost updates via the Tauri updater plugin. It dynamically imports the @tauri-apps/plugin-updater package (not loaded at startup to avoid blocking the initial render). The update check has a 30-second timeout and only runs inside the Tauri environment (checks for __TAURI_INTERNALS__ on the window object).

The download flow tracks progress through three phases: Started (sets total bytes), Progress (increments downloaded bytes and calculates percentage), and Finished (sets 100% and marks as installed). After installation, relaunch() is called to restart the app.

All stores that handle WebSocket events follow the same pattern:

  1. Event arrives in App.tsx’s handleWSEvent callback
  2. App routes the event to the appropriate store’s sync method based on the event type string
  3. Store updates its state optimistically (directly mutates the Zustand state — no re-fetch from the server)
  4. Related stores may be notified (e.g., flow creation triggers stats refresh, finding creation bumps the findings version counter and shows a toast)

This means the frontend stays in sync with the backend without polling. WebSocket is the primary data channel for all real-time updates, and the REST API is used only for initial data loading, user-initiated actions, and the polling fallback when WebSocket is disconnected.

42 distinct WebSocket event types are routed to 12 different stores (2 events are received but not surfaced in the UI):

EventsTarget Store
flow.created, flow.updated, flow.deleteduseFlowStore
session.created, session.updated, session.deleteduseSessionStore
proxy.started, proxy.stopped, proxy.statususeProxyStore
addon.created, addon.updated, addon.deleted, addon.loguseAddonStore
extension.connected, extension.disconnected, extension.interaction, extension.tab_switchuseExtensionStore
extension.console_error, extension.navigation(received but not surfaced)
flow.breakpoint, flow.resumeduseBreakpointStore
rule.created, rule.updated, rule.deleteduseRulesStore
injection_rule.created, injection_rule.updated, injection_rule.deleteduseInjectionRulesStore
device.discovered, device.updated, device.connected, device.disconnected, device.removeduseDeviceStore
artifact.created, artifact.deleteduseArtifactStore
finding.created, finding.updated, finding.deleteduseUIStore
frida.session.started, frida.session.stopped, frida.outputuseFridaStore
attacker:started, attacker:progress, attacker:completed, attacker:erroruseAttackerStore
StoreConstantValueWhat Happens When Exceeded
useFlowStoreFetch limit5,000Only the most recent 5,000 flows are loaded per fetch. Use fetchMore() to paginate.
useFlowStoreBatch interval100msaddFlow and updateFlow events are buffered and applied in batches every 100ms to reduce React re-renders.
useAddonStoreMAX_LOGS500Oldest log entries are trimmed. Newest entries are prepended.
useExtensionStoreMAX_RECENT_INTERACTIONS50Oldest interactions are dropped.
useFridaStoreMAX_CONSOLE_LINES500 per sessionOldest console lines are trimmed per Frida session.
useFridaStoreCONSOLE_BATCH_INTERVAL100msConsole output lines are buffered and flushed every 100ms.
useAttackerStoreMAX_LATEST_RESULTS50Only the 50 most recent interesting attack results are kept.
useDeviceStoreCONNECTING_TIMEOUT_MS120,000ms (2 min)Device auto-transitions to error state with a timeout message.
useUpdaterStoreCheck timeout30,000ms (30s)Update check times out after 30 seconds.