State and Data Skill
Applicability
- Platforms: iOS and Android
- React Native: 0.76+ (New Architecture interop assumed unless a checklist item says otherwise)
When to Use
- Wiring a screen up to an API
- Reviewing how server state is fetched, cached, and displayed
- Handling offline mode, network transitions, or flaky connections
- Implementing payments, checkout, or other critical transactions
Severity
- Merge-blocking: a POST this diff adds is retried when the network returns, or a payment is treated as success from the client callback alone.
- Should-fix: cache freshness, optimistic updates, and empty-state copy.
Guidance
Server State
Keep server data in one cache. That cache may be the store the app already uses.
- Server data is not copied into a second
useStatethat can drift from the cache - Freshness is chosen per data type, rather than refetching on every mount
- An optimistic update rolls back when the request fails
- Loading and empty states are shown for the request this diff adds
- A failed request shows an error state. The fallback and retry policy are owned by error-handling
Incorrect:
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);Correct:
const users = readFromAppCache('users', () => api.getUsers());Form & Local State
Dirty form input, validation, and unsaved-change prompts are owned by forms-and-validation.
- Non-form UI state is not reset by keyboard appearance or orientation change
Offline & Network Transitions
- Clear offline indicator shown to the user
- Cached data shown for read operations when offline
- Destructive mutations require confirmation before queuing offline
- Mid-request network drop handled with a timeout, not an infinite spinner
- In-progress requests cancelled on screen unmount
Incorrect:
// No timeout — hangs forever on a slow network
const data = await fetch('/api/items');Correct:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const data = await fetch('/api/items', { signal: controller.signal });
} catch (e) {
if (e.name === 'AbortError') showTimeout();
else showError(e);
} finally {
clearTimeout(timeout);
}Transactions & Critical Actions
- Success confirmed server-side, not from a client callback alone
- Interruption handled (user kills the app mid-transaction)
- External redirects (OAuth, 3DS) return cleanly to the app
- POST requests this diff adds are not auto-retried when the network returns (this skill owns that rule)
Incorrect:
const onPay = async () => {
await paymentSDK.charge(amount);
navigation.navigate('Success'); // assumes success from the SDK alone
};Correct:
const onPay = async () => {
const sdkResult = await paymentSDK.charge(amount);
const confirmed = await api.confirmPayment(sdkResult.transactionId);
if (confirmed.status === 'success') {
navigation.navigate('Success');
} else {
navigation.navigate('PaymentFailed', { reason: confirmed.reason });
}
};Anti-Patterns
| Anti-Pattern | Risk | Fix |
|---|---|---|
A second useState copy of server data |
The screen and the cache disagree | Read from the one cache the app already uses |
| A second store that copies each server response | The two copies drift | Read from that same cache |
| Fetching inside the component | No shared cache, repeated requests | Read through the app cache |
| Fire-and-forget async | Silent failures | Always handle errors |
| Auto-retry POST on reconnect | Duplicate transactions | Retry only idempotent reads |
Pitfalls
- A cache that treats every read as stale refetches on every focus, which wastes bandwidth and battery.
- Optimistic updates without rollback on error leave the UI in an impossible state.
- Not cleaning up an
AbortControlleron unmount triggers a state update on an unmounted component. - Showing stale cached data without an offline indicator makes users think it is current.
- Not storing transaction intent before starting means an app crash equals lost state.