Skip to main content
ANVISoftware Solutions
Lesson 4 of 11Intermediate18 min

State Management

By the end of this lesson

Manage state across screens and app lifecycle events.

State management on mobile is two questions wearing one name. The first is the familiar one: which screens need to see the same data, and how do they stay in agreement. The second is specific to devices: what survives when your app stops running.

The first question has the answers you already know — keep state close to where it is used, lift it when two screens need it, and have one source of truth for anything a user can change. Those apply unchanged here.

The second question is the one that catches people, because the failure is invisible during development. You run the app, you use it, everything works. Then a real user switches to their camera for four minutes and comes back to an empty form.

The lifecycle, in the terms both platforms broadly share. The names differ between them; the sequence does not:

Foreground
On screen and interactive. Your code runs freely, timers fire, requests complete. This is the only state in which you can assume anything about timing.
Background
The user has left, but you have a brief moment — short, and not a number you should rely on — to finish urgent work. This is where you persist anything you want back. Long tasks need an explicit platform mechanism, not hope.
Suspended
Still in memory, not executing. Your timers do not fire and your requests do not progress. From your code's point of view, time stops and then resumes later with a gap you did not see.
Terminated
The process is gone, reclaimed so its memory could go to the foreground app. You are not told, there is no callback, and every variable you held no longer exists. The user may have no idea it happened.
Cold launch
Starting from nothing after termination. The user often expects to continue where they were, because from their side they only switched apps and came back. Whether they can depends entirely on what you wrote down earlier.

Termination is not an error condition. It is routine memory management, and it happens more on older and cheaper devices, which is to say the devices many of your users actually have. An engineer's phone with a mapping app, a camera, a messaging app and a browser open is a device under pressure, and your backgrounded app is a reasonable thing for the system to reclaim.

So the design question becomes: what would the user be upset to lose? Sort state into three tiers and the answer stops being a judgement call every time.

Three tiers, each with a different obligation:

Ephemeral — fine to lose
Scroll position in a long list, whether a section is expanded, an in-progress animation, the text in a search box. Restoring it is a nicety. Persisting all of it produces write churn and code nobody maintains, so let it go.
Restorable — persist to continue
Where the user was and what they had typed. The current screen and its parameters, a half-written visit note, a partially completed form, the identifier of the record being edited. Losing this is the difference between a seamless return and visible failure. Write it as the app goes to the background.
Durable — persist and reconcile
Anything the user considers saved, and anything queued to send to the server. A submitted visit note that has not reached the employees API yet lives here. It survives termination, survives reinstall-free upgrades, and gets reconciled with the server later — the subject of the offline lesson.
Persisting on the way out, restoring on the way in
TypeScript
interface VisitDraft {
  employeeId: number;
  notes: string;
  screen: string;
  updatedAt: number;
}

interface KeyValueStore {
  read(key: string): Promise<string | null>;
  write(key: string, value: string): Promise<void>;
  remove(key: string): Promise<void>;
}

const DRAFT_KEY = "restore.visitDraft";
const MAX_AGE_MS = 1000 * 60 * 60 * 12;

/** Called when the app moves to the background, not on every keystroke. */
export async function saveDraft(store: KeyValueStore, draft: VisitDraft) {
  await store.write(DRAFT_KEY, JSON.stringify(draft));
}

export async function restoreDraft(
  store: KeyValueStore,
  now: number,
): Promise<VisitDraft | null> {
  const raw = await store.read(DRAFT_KEY);
  if (raw === null) return null;

  try {
    const draft = JSON.parse(raw) as VisitDraft;
    if (typeof draft.notes !== "string" || !Number.isInteger(draft.employeeId)) {
      return null;
    }
    if (now - draft.updatedAt > MAX_AGE_MS) return null;
    return draft;
  } catch {
    await store.remove(DRAFT_KEY);
    return null;
  }
}
  • Read this as a shape. Every platform has a small key-value store and a way to be told the app is backgrounding; the names differ and the sequence does not.
  • Writing on background rather than on every keystroke is deliberate. Per-keystroke writes cost battery and wear, and the background moment is the one point where you know the state is worth capturing. If a note takes several minutes to write, add a timed save every twenty or thirty seconds as well — the background callback is not guaranteed to complete.
  • The stored value is validated, not trusted. It was written by an earlier version of your own app, possibly one with a different field layout, and it may have been cut short if the process ended mid-write. A parse failure is an ordinary outcome, so the catch clears the bad value instead of crashing on launch.
  • The age check exists because a restored draft has to be plausible. Offering someone yesterday's half-written note as though they were mid-sentence is confusing, and twelve hours is a defensible line for an app used on shift. Pick your own number and say why in a comment.
  • Restoring a screen name and its parameters is what makes continuation feel seamless. Restore into a validated state, though: if the employee record has since been deleted, land on a screen that explains that rather than one that immediately fails to load.

Summary

  • Foreground, background, suspended, terminated: only in the foreground can you assume your code keeps running, and termination is routine memory management rather than an error
  • Sort state into ephemeral, restorable and durable, and give each tier a different obligation
  • Persist what the user typed and where they were, on background and on a timer, then validate it on the way back in
  • Persisting everything costs battery and creates stale-state bugs, so choose per tier rather than saving by reflex
  • Model user intent, such as a pending change, rather than transient facts like a request being open

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Simulate termination on purpose

Take any app you are building, or one you use, and start entering something substantial: a long note, a multi-step form.

Background it, then force the process to end using your platform's developer tooling or by opening several heavy apps. Relaunch and see what survived.

Then do the same with the app in the background for only two seconds, and note the difference.

Show solution

A short background and return usually restores perfectly, because the process was never reclaimed. That is exactly why this bug reaches production: the common case works, so nothing prompts you to check the uncommon one.

After a forced termination you learn what the app actually wrote down. Apps that persist properly reopen on the same screen with the text intact. Apps that do not open on their home screen, having lost everything, with no acknowledgement that anything was lost.

The reason to make this a habit rather than a one-off: it is the only way to test the code path your users hit most on loaded devices, and it takes under a minute once you know the tooling.

Think about it

Sort the state on one screen

The visit note screen holds: the employee record fetched from the API, the notes text the engineer is typing, the scroll position, whether the attachments section is expanded, a photo already taken but not uploaded, and a flag saying a save is in progress.

Put each into ephemeral, restorable or durable, and say what happens to each on termination. The save-in-progress flag deserves particular thought.

Show solution

Ephemeral: scroll position and the expanded section. Nice to restore, no real loss, not worth the write.

Restorable: the notes text and the screen identity. Write these on background and on a timer. The fetched employee record is also restorable, though it is better handled as cache — covered in the offline lesson — because it has an age and a server copy.

Durable: the photo, without question. The engineer took it at a site they have left, and the original may not exist anywhere else. It goes to disk the moment it is captured and into a queue for upload.

The save-in-progress flag is the trap. Persisting it as true and restoring it means the app opens showing a spinner for a request that died with the process, and it will spin forever. But dropping it loses the fact that a save was attempted, so the user cannot tell whether their note reached the server.

The real answer is that a boolean was the wrong model. Persist the intent — a queued change that is pending, sent or confirmed — rather than the transient fact that a request is currently open. On launch you can then reconcile: ask the server, or resend safely, and tell the user honestly what state their note is in. This is the shift from tracking UI activity to tracking user intent, and it is the design that makes offline behaviour possible at all.

Saved in this browser only.