plugins/lisa-expo/skills/local-state/SKILL.md
This skill should be used when implementing local state management in this React Native/Expo codebase. It covers Apollo Client Reactive Variables for in-memory reactive state and React Native AsyncStorage for persistent storage. Use this skill when creating feature flags, user preferences, session state, or any client-only state that needs to survive component unmounts or app restarts.
npx skillsauth add codyswanngt/lisa local-stateInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill provides best practices for local state management in this React Native/Expo codebase. Local state is managed using two complementary approaches:
| Use Case | Approach | Example |
| ------------------------------- | -------------------------------- | ------------------------------ |
| UI state that resets on refresh | Reactive Variable only | Modal open state, form drafts |
| User preferences that persist | Reactive Variable + AsyncStorage | Theme, language, notifications |
| Feature flags | Reactive Variable + AsyncStorage | Beta features, profiler toggle |
| Session-scoped data | Reactive Variable only | Current filter selections |
| Cross-component communication | Reactive Variable | Selected player ID, active tab |
| Authentication tokens | expo-secure-store | Access tokens, refresh tokens |
import { makeVar } from "@apollo/client";
interface IUserPreferences {
readonly theme: "light" | "dark";
readonly notifications: boolean;
}
const DEFAULT_PREFERENCES: IUserPreferences = {
theme: "light",
notifications: true,
};
export const userPreferencesVar =
makeVar<IUserPreferences>(DEFAULT_PREFERENCES);
import { useReactiveVar } from "@apollo/client";
const MyComponent = () => {
const preferences = useReactiveVar(userPreferencesVar);
return <Text>{preferences.theme}</Text>;
};
// Update with new object reference
userPreferencesVar({
...userPreferencesVar(),
theme: "dark",
});
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = "@whatever:user-preferences";
export const savePreferences = async (
prefs: IUserPreferences
): Promise<void> => {
userPreferencesVar(prefs); // Update reactive variable first
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Failed to save preferences:", message);
}
};
export const loadPreferences = async (): Promise<void> => {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as IUserPreferences;
userPreferencesVar({ ...DEFAULT_PREFERENCES, ...parsed });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Failed to load preferences:", message);
}
};
Never mutate existing objects or arrays. Create new references to trigger reactivity:
// CORRECT - creates new object
userPreferencesVar({
...userPreferencesVar(),
theme: "dark",
});
// INCORRECT - mutation does NOT trigger updates
const prefs = userPreferencesVar();
prefs.theme = "dark"; // This does nothing!
userPreferencesVar(prefs); // Same reference, no update
useReactiveVar for Reactive ComponentsCalling myVar() directly does NOT trigger re-renders. Always use the hook:
// CORRECT - component re-renders when variable changes
const theme = useReactiveVar(themeVar);
// INCORRECT - no re-renders on variable change
const theme = themeVar();
Create custom hooks for testability and encapsulation:
export const useTheme = () => {
const preferences = useReactiveVar(userPreferencesVar);
const setTheme = useCallback((theme: "light" | "dark") => {
savePreferences({ ...userPreferencesVar(), theme });
}, []);
return { theme: preferences.theme, setTheme };
};
Prefix all AsyncStorage keys with the app namespace:
// CORRECT
const STORAGE_KEY = "@whatever:filter-values";
// INCORRECT - collision risk
const STORAGE_KEY = "filters";
AsyncStorage operations can fail. Always use try/catch:
// CORRECT
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(`Failed to save ${key}:`, message);
}
// INCORRECT - silent failures
await AsyncStorage.setItem(key, JSON.stringify(value));
AsyncStorage is unencrypted. Use expo-secure-store for sensitive data:
// CORRECT - use secure store for tokens
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("accessToken", token);
// INCORRECT - never store tokens in AsyncStorage
await AsyncStorage.setItem("accessToken", token);
Initialize persisted state early in the app lifecycle:
// In root layout or app initialization
useEffect(() => {
loadPreferences();
}, []);
Organize reactive variables and persistence logic in dedicated store files:
features/
my-feature/
stores/
featureState.ts # Reactive variable + persistence logic
index.ts # Re-exports
Example store file structure:
// stores/userPreferences.ts
import { makeVar, useReactiveVar } from "@apollo/client";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCallback } from "react";
// Types
interface IUserPreferences {
readonly theme: "light" | "dark";
readonly language: string;
}
// Constants
const STORAGE_KEY = "@whatever:user-preferences";
const DEFAULT_PREFERENCES: IUserPreferences = {
theme: "light",
language: "en",
};
// Reactive Variable
export const userPreferencesVar =
makeVar<IUserPreferences>(DEFAULT_PREFERENCES);
// Persistence Functions
export const loadUserPreferences = async (): Promise<void> => {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as IUserPreferences;
userPreferencesVar({ ...DEFAULT_PREFERENCES, ...parsed });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Failed to load preferences:", message);
}
};
const saveUserPreferences = async (prefs: IUserPreferences): Promise<void> => {
userPreferencesVar(prefs);
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Failed to save preferences:", message);
}
};
// Custom Hook
export const useUserPreferences = () => {
const preferences = useReactiveVar(userPreferencesVar);
const setTheme = useCallback((theme: "light" | "dark") => {
saveUserPreferences({ ...userPreferencesVar(), theme });
}, []);
const setLanguage = useCallback((language: string) => {
saveUserPreferences({ ...userPreferencesVar(), language });
}, []);
return { preferences, setTheme, setLanguage };
};
For comprehensive patterns and examples, see the reference files:
// WRONG - mutation
const filters = filtersVar();
filters.minAge = 25;
filtersVar(filters);
// CORRECT - new reference
filtersVar({ ...filtersVar(), minAge: 25 });
localStorage in React Native// WRONG - doesn't exist in React Native
localStorage.setItem("key", value);
// CORRECT - use AsyncStorage
await AsyncStorage.setItem("key", value);
myVar() expecting re-renders// WRONG - no reactivity
const Component = () => {
const value = myVar(); // Does NOT trigger re-renders
return <Text>{value}</Text>;
};
// CORRECT - reactive
const Component = () => {
const value = useReactiveVar(myVar);
return <Text>{value}</Text>;
};
// WRONG - AsyncStorage only accepts strings
await AsyncStorage.setItem("key", { name: "John" });
// CORRECT - serialize first
await AsyncStorage.setItem("key", JSON.stringify({ name: "John" }));
// WRONG - state resets on app restart
export const filtersVar = makeVar<Filters>(DEFAULT_FILTERS);
// CORRECT - load on app start
export const filtersVar = makeVar<Filters>(DEFAULT_FILTERS);
export const loadFilters = async () => {
/* ... */
};
// Call loadFilters() in app initialization
When writing or reviewing local state code, verify:
makeVar<Type>(default)useReactiveVar() hook, not direct myVar() calls@whatever:expo-secure-store, not AsyncStoragestores/ directorydevelopment
Prepare a machine — a fresh laptop or a throwaway container — to run coding agents, before any repository exists. Detects which of Lisa's supported agents (Claude Code, Codex, Cursor, OpenCode, Antigravity, Copilot) are already installed, asks which credential manager the machine uses (Bitwarden, 1Password, Doppler, Vault, AWS, or none), and installs only what is missing, each by its vendor's own preferred method. Idempotent, headless by default, and emits a Dockerfile for a spin-up/spin-down environment. Run it on a new machine, in a container, or before cloning anything.
tools
Provision and verify a remote execution environment for a host project — Codex Cloud today, other remote surfaces as they are added. Generates a repository-owned setup script that installs the declared toolchain, materializes secrets through lisa-secrets-access, and runs the project's own hook. Provisions by API where one exists, by driving the vendor console where one does not, and by emitting exact config otherwise — then proves the result with the same read-back regardless of which tier did the work. Use before dispatching any work with executionEnv.
tools
Bring a developer's machine in line with the toolchain the project declares. Reports every tool in remoteEnv.tools that is missing, outdated, or unpinned for this platform, and installs the missing ones into ~/.local/bin from the same pinned, checksummed entries the remote surfaces use — but only when asked. Same manifest, same pins, same installers as lisa-setup-remote-env; what differs is consent and that the pin is a floor rather than an equality. Run it on a fresh checkout, after a manifest change, or when a tool fails at the moment of use.
tools
Route one unit of work to a remote execution surface. Reads the executionEnv parameter (local by default, codex-cloud or claude-web today), verifies the environment is provisioned and bound to this repository, submits a thin skill invocation, records the task identifier to .lisa/remote-dispatch.json, and exits without polling. Routing only — the remote runs the identical skill from the identical repository. Composable and inline: other skills invoke it via the Skill tool rather than users calling it directly.