Skip to main content

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

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.

Data Entities

Exercises

id
string
required
Unique identifier (format: ex_{timestamp})
name
string
required
Exercise name (e.g., “Squat”, “Bench Press”)
notes
string
Optional notes about form, technique, etc.
Example Usage
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);
  }
}
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,
    },
  },
});
refetchInterval
false
Manual refetching only - no automatic polling
refetchOnWindowFocus
false
Prevents refetching when app returns to foreground
staleTime
Infinity
Cached data never becomes stale automatically
retry
false
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

1

Component Mounts

Screen component loads and needs exercise data
2

Read from AsyncStorage

Call getExercises() to retrieve local data
3

Return Cached Data

If data exists, return immediately (instant UI update)
4

Initialize Defaults

If no data, initialize from rippler-program.ts defaults
5

Save Defaults

Persist defaults to AsyncStorage for next time

Example 2: Logging a Workout

1

User Completes Workout

User fills out sets, reps, and weights in WorkoutScreen
2

Create LoggedWorkout Object

Build workout object with all exercise data
3

Save Locally

Call saveLoggedWorkout() to persist to AsyncStorage
4

Update UI Optimistically

UI updates immediately showing workout as logged
5

Sync to Server (Optional)

React Query mutation sends data to API for backup
6

Invalidate Queries

Invalidate history queries to refresh stats

Example 3: Setting Goal Weights

Example Implementation
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
  );
}

Performance Optimization

Batch Operations

When updating multiple records, batch AsyncStorage calls:
Batch Update Pattern
// 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:
Optimistic Update
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

Error Handling Pattern
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

API Error Handling
async function throwIfResNotOk(res: Response) {
  if (!res.ok) {
    const text = (await res.text()) || res.statusText;
    throw new Error(`${res.status}: ${text}`);
  }
}

Best Practices

Never call AsyncStorage directly. Use the typed wrapper functions in lib/storage.ts to ensure type safety and consistent error handling.
Always provide sensible defaults when data doesn’t exist. Don’t assume AsyncStorage will have data.
AsyncStorage returns strings. After JSON.parse(), validate the structure matches expected types.
Only invalidate queries when data truly changes. Over-invalidation causes unnecessary re-fetches.
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

Build docs developers (and LLMs) love