Skip to content

Storage

Edgar Mesquita edited this page Aug 10, 2026 · 2 revisions

Storage

One signature over each platform's own native store. An app writes the same C# everywhere, and the value lands where that platform already keeps this kind of thing — so its backup, its migration and its uninstall all behave the way a user expects, without the framework inventing or bundling anything.

There are two interfaces, and which one you take is a decision, not a parameter.

IAppStorage ISecretStore
Web localStorage none — resolves null
macOS / iOS NSUserDefaults Keychain
Android SharedPreferences KeyStore-backed AES/GCM

Both are taken through a constructor, like every other capability:

public sealed class SettingsShell(IAppStorage storage) : StatefulComponent
{
    private void Remember(string language) => storage.Set("language", language);
}

Native today, web pending. Constructor injection is resolved by the .NET container, which is what constructs a component on Photon. The browser's mount does not yet do the same — it constructs a page with no arguments — so a write-once page on the web receives null for a capability it asks for this way. The web realizations exist and are registered; what is missing is the mount resolving them. Until it lands, reach storage from native code, or from web code through your own composition rather than a page constructor.

Why secrets are a different type

Putting a token in localStorage is the most-made security mistake on the web, and an API where safety is a secure: true argument is an API where safety is one forgotten argument away. Here the choice cannot be made by omission: a secret needs a different service, and asking for it is something somebody did on purpose.

The browser has no vault, so on the web ISecretStore resolves to null — the same way the framework reports every capability a host does not have, rather than pretending with a store any script on the origin can read. An app that reaches for it on the web has to answer the question it was avoiding: keep the secret on the server and hand the browser a session it can only spend, or accept that what it was storing was never really a secret.

On native it is the platform's real vault. Apple: one kSecClassGenericPassword item per key under the app's bundle identifier, AfterFirstUnlockThisDeviceOnly so a background refresh can read it while a device not unlocked since boot cannot, and never synced to iCloud. Android: a hardware-backed AES key in AndroidKeyStore — key material unreadable even by this process — with AES/GCM ciphertext in a preferences file. That is what AndroidX's EncryptedSharedPreferences does, written directly because the platform ships every piece and the framework does not take a dependency for something the OS already provides.

Strings only

Every store here is a string map underneath. A typed surface would be three serializers to keep identical, and their first disagreement is a value that writes on one platform and cannot be read on another. Serialize in your app, where the format is yours.

Storage is a CLIENT capability

There is no server realization. During SSR the service resolves to null and reads answer nothing.

This matters more than it sounds. A page that branches its tree on stored state renders one thing on the server and another in the browser, and the reconciler will faithfully reconcile the difference in front of the reader. Read storage in an event handler or after mount, never while building.

It is also why storage alone cannot remember a theme without a flash: the server paints first and localStorage is not something it can see. That specific problem wants a cookie, because the requirement is not "remember" but "tell the server".

Failures answer, they never throw

  • localStorage is the DOM API most likely to throw rather than fail quietly — Safari's private mode has thrown on write, a browser at quota throws, and reading it at all throws when the origin has storage blocked. A preference failing to save must not take a page down, so a write that cannot happen simply does not, and a read that cannot happen answers null.
  • A Keychain item does not survive every restore, and a KeyStore key is invalidated by a new lock screen. Both make every value under them permanently unreadable.

So treat a null read as "ask again" — sign in again, fall back to the default — never as an error to report. It is the same answer a key that was never written gives, and the caller already handles that one.

Bringing your own

Every capability is registered with TryAdd, so registering your own wins:

builder.Services.AddSingleton<IAppStorage, MyOwnStorage>();

That is the escape hatch for a store with different durability, an encrypted file, or a test double.

Clone this wiki locally