Data Flow Overview
Rippler implements a hybrid data architecture combining local-first storage with server synchronization:
All data is first persisted locally via AsyncStorage, then synchronized with the server when available. This ensures the app works offline and provides instant feedback.
Local Storage Layer
AsyncStorage Implementation
The storage layer is abstracted in client/lib/storage.ts with typed functions for each data entity:
client/lib/storage.ts:5-11
const KEYS = {
EXERCISES: "@rippler/exercises" ,
LOGGED_WORKOUTS: "@rippler/logged_workouts" ,
CURRENT_WEEK: "@rippler/current_week" ,
TARGET_OVERRIDES: "@rippler/target_overrides" ,
GOAL_WEIGHTS: "@rippler/goal_weights" ,
};
Storage Patterns
Read Pattern
Write Pattern
Update Pattern
Delete Pattern
Reading data with fallback to defaults: client/lib/storage.ts:13-31
export async function getExercises () : Promise < Exercise []> {
try {
const data = await AsyncStorage . getItem ( KEYS . EXERCISES );
if ( data ) {
return JSON . parse ( data );
}
// Initialize with default exercises from program
const defaultExercises : Exercise [] = ripplerProgram . exercises . map (
( name , index ) => ({
id: `ex_ ${ index } ` ,
name ,
})
);
await AsyncStorage . setItem ( KEYS . EXERCISES , JSON . stringify ( defaultExercises ));
return defaultExercises ;
} catch ( error ) {
console . error ( "Error getting exercises:" , error );
return [];
}
}
Always wrap AsyncStorage calls in try/catch blocks. Network errors or storage quota issues can throw exceptions.
Writing data with error handling: client/lib/storage.ts:33-39
export async function saveExercises ( exercises : Exercise []) : Promise < void > {
try {
await AsyncStorage . setItem ( KEYS . EXERCISES , JSON . stringify ( exercises ));
} catch ( error ) {
console . error ( "Error saving exercises:" , error );
}
}
Updating individual records: client/lib/storage.ts:53-63
export async function updateExercise (
id : string ,
updates : Partial < Exercise >
) : Promise < void > {
const exercises = await getExercises ();
const index = exercises . findIndex (( e ) => e . id === id );
if ( index !== - 1 ) {
exercises [ index ] = { ... exercises [ index ], ... updates };
await saveExercises ( exercises );
}
}
Deleting records: client/lib/storage.ts:65-69
export async function deleteExercise ( id : string ) : Promise < void > {
const exercises = await getExercises ();
const filtered = exercises . filter (( e ) => e . id !== id );
await saveExercises ( filtered );
}
Data Entities
Exercises
Unique identifier (format: ex_{timestamp})
Exercise name (e.g., “Squat”, “Bench Press”)
Optional notes about form, technique, etc.
import { addExercise , getExercises , updateExercise } from '@/lib/storage' ;
// Add new exercise
const exercise = await addExercise ( "Romanian Deadlift" , "Focus on hip hinge" );
// Get all exercises
const exercises = await getExercises ();
// Update exercise
await updateExercise ( exercise . id , { notes: "Keep back neutral" });
Logged Workouts
Workouts are stored with complete set-by-set data:
client/lib/storage.ts:84-97
export async function saveLoggedWorkout ( workout : LoggedWorkout ) : Promise < void > {
try {
const workouts = await getLoggedWorkouts ();
const existingIndex = workouts . findIndex (( w ) => w . id === workout . id );
if ( existingIndex !== - 1 ) {
workouts [ existingIndex ] = workout ;
} else {
workouts . push ( workout );
}
await AsyncStorage . setItem ( KEYS . LOGGED_WORKOUTS , JSON . stringify ( workouts ));
} catch ( error ) {
console . error ( "Error saving logged workout:" , error );
}
}
LoggedWorkout Type Definition
client/types/workout.ts:38-45
export interface LoggedWorkout {
id : string ;
week : number ;
day : string ;
dateLogged : string ;
exercises : LoggedExercise [];
completed : boolean ;
}
export interface LoggedExercise {
tier : Tier ;
exercise : string ;
sets : LoggedSet [];
notes ?: string ;
}
export interface LoggedSet {
setNumber : number ;
weight : number | string ;
reps : number | string ;
completed : boolean ;
}
Current Week
Tracks progression through the program:
client/lib/storage.ts:117-136
export async function getCurrentWeek () : Promise < number > {
try {
const data = await AsyncStorage . getItem ( KEYS . CURRENT_WEEK );
if ( data ) {
return parseInt ( data , 10 );
}
return 1 ;
} catch ( error ) {
console . error ( "Error getting current week:" , error );
return 1 ;
}
}
export async function setCurrentWeek ( week : number ) : Promise < void > {
try {
await AsyncStorage . setItem ( KEYS . CURRENT_WEEK , week . toString ());
} catch ( error ) {
console . error ( "Error setting current week:" , error );
}
}
Target Overrides
Allows users to customize prescribed weights/reps:
client/lib/storage.ts:151-169
export async function saveTargetOverride ( override : TargetOverride ) : Promise < void > {
try {
const overrides = await getTargetOverrides ();
const existingIndex = overrides . findIndex (
( o ) =>
o . week === override . week &&
o . day === override . day &&
o . exerciseIndex === override . exerciseIndex
);
if ( existingIndex !== - 1 ) {
overrides [ existingIndex ] = override ;
} else {
overrides . push ( override );
}
await AsyncStorage . setItem ( KEYS . TARGET_OVERRIDES , JSON . stringify ( overrides ));
} catch ( error ) {
console . error ( "Error saving target override:" , error );
}
}
Goal Weights
Stores user’s target weights for each exercise:
client/lib/storage.ts:220-227
export async function setGoalWeight (
exerciseName : string ,
weight : number
) : Promise < void > {
const goals = await getGoalWeights ();
goals [ exerciseName ] = weight ;
await saveGoalWeights ( goals );
}
Server State Management
React Query Configuration
React Query handles all server communication with opinionated defaults:
client/lib/query-client.ts:66-79
export const queryClient = new QueryClient ({
defaultOptions: {
queries: {
queryFn: getQueryFn ({ on401: "throw" }),
refetchInterval: false ,
refetchOnWindowFocus: false ,
staleTime: Infinity ,
retry: false ,
},
mutations: {
retry: false ,
},
},
});
Manual refetching only - no automatic polling
Prevents refetching when app returns to foreground
Cached data never becomes stale automatically
Failed requests don’t retry (fail fast)
API Request Helper
Centralized API request function with error handling:
client/lib/query-client.ts:26-43
export async function apiRequest (
method : string ,
route : string ,
data ?: unknown | undefined ,
) : Promise < Response > {
const baseUrl = getApiUrl ();
const url = new URL ( route , baseUrl );
const res = await fetch ( url , {
method ,
headers: data ? { "Content-Type" : "application/json" } : {},
body: data ? JSON . stringify ( data ) : undefined ,
credentials: "include" ,
});
await throwIfResNotOk ( res );
return res ;
}
Query Function Factory
Custom query function with 401 handling:
client/lib/query-client.ts:46-64
export const getQueryFn : < T >( options : {
on401 : UnauthorizedBehavior ;
}) => QueryFunction < T > =
({ on401 : unauthorizedBehavior }) =>
async ({ queryKey }) => {
const baseUrl = getApiUrl ();
const url = new URL ( queryKey . join ( "/" ) as string , baseUrl );
const res = await fetch ( url , {
credentials: "include" ,
});
if ( unauthorizedBehavior === "returnNull" && res . status === 401 ) {
return null ;
}
await throwIfResNotOk ( res );
return await res . json ();
};
Data Flow Examples
Example 1: Loading Exercises
Component Mounts
Screen component loads and needs exercise data
Read from AsyncStorage
Call getExercises() to retrieve local data
Return Cached Data
If data exists, return immediately (instant UI update)
Initialize Defaults
If no data, initialize from rippler-program.ts defaults
Save Defaults
Persist defaults to AsyncStorage for next time
Example 2: Logging a Workout
User Completes Workout
User fills out sets, reps, and weights in WorkoutScreen
Create LoggedWorkout Object
Build workout object with all exercise data
Save Locally
Call saveLoggedWorkout() to persist to AsyncStorage
Update UI Optimistically
UI updates immediately showing workout as logged
Sync to Server (Optional)
React Query mutation sends data to API for backup
Invalidate Queries
Invalidate history queries to refresh stats
Example 3: Setting Goal Weights
import { useState } from 'react' ;
import { setGoalWeight , getGoalWeight } from '@/lib/storage' ;
function GoalsScreen () {
const [ squat , setSquat ] = useState < number | null >( null );
// Load goal on mount
useEffect (() => {
getGoalWeight ( "Squat" ). then ( setSquat );
}, []);
// Save goal
const handleSave = async ( weight : number ) => {
await setGoalWeight ( "Squat" , weight );
setSquat ( weight );
};
return (
// UI implementation
);
}
Batch Operations
When updating multiple records, batch AsyncStorage calls:
// Bad: Multiple AsyncStorage calls
for ( const exercise of exercises ) {
await updateExercise ( exercise . id , updates );
}
// Good: Single AsyncStorage call
const exercises = await getExercises ();
const updated = exercises . map ( ex => ({ ... ex , ... updates }));
await saveExercises ( updated );
Query Deduplication
React Query automatically deduplicates identical requests:
// Both components share the same query cache
function ComponentA () {
const { data } = useQuery ([ 'exercises' ]);
}
function ComponentB () {
const { data } = useQuery ([ 'exercises' ]); // Uses cached data
}
Optimistic Updates
Update UI immediately before server confirmation:
const mutation = useMutation ({
mutationFn : ( workout ) => apiRequest ( 'POST' , '/workouts' , workout ),
onMutate : async ( newWorkout ) => {
// Save locally first
await saveLoggedWorkout ( newWorkout );
// Cancel outgoing queries
await queryClient . cancelQueries ([ 'workouts' ]);
// Snapshot previous value
const previous = queryClient . getQueryData ([ 'workouts' ]);
// Optimistically update cache
queryClient . setQueryData ([ 'workouts' ], old => [ ... old , newWorkout ]);
return { previous };
},
onError : ( err , newWorkout , context ) => {
// Rollback on error
queryClient . setQueryData ([ 'workouts' ], context . previous );
},
});
Error Handling
Storage Errors
try {
await AsyncStorage . setItem ( key , value );
} catch ( error ) {
if ( error . message . includes ( 'QuotaExceededError' )) {
// Handle storage quota exceeded
await clearOldData ();
} else {
// Log error for debugging
console . error ( 'Storage error:' , error );
}
}
Network Errors
async function throwIfResNotOk ( res : Response ) {
if ( ! res . ok ) {
const text = ( await res . text ()) || res . statusText ;
throw new Error ( ` ${ res . status } : ${ text } ` );
}
}
Best Practices
Always use typed storage functions
Never call AsyncStorage directly. Use the typed wrapper functions in lib/storage.ts to ensure type safety and consistent error handling.
Handle missing data gracefully
Always provide sensible defaults when data doesn’t exist. Don’t assume AsyncStorage will have data.
Validate data after parsing
AsyncStorage returns strings. After JSON.parse(), validate the structure matches expected types.
Use query invalidation strategically
Only invalidate queries when data truly changes. Over-invalidation causes unnecessary re-fetches.
Implement offline-first UX
Save to AsyncStorage immediately, sync to server in background. The app should work fully offline.
Navigation Learn how navigation state is managed
TypeScript Types Explore type definitions for data entities