frontend: flesh and rig react-query + mmkv
Pedro Lucas Porcellis porcellis@eletrotupi.com · 5 months ago
c6d61c3d0df216df84ee31bc55dd02e9d0073fce Browse filesParents
9178180Changed files
7
Additions
+258
Deletions
-37
frontend/app/(tabs)/new/entry.tsx
1 1
import { useState, useEffect } from 'react';
2 2
import {
3 3
Link, router, useLocalSearchParams
4 4
} from 'expo-router';
5 5
22 22
import { Section, SectionHeader } from '@/components/ui/Sections';
23 23
import { Spacing, Typography, Colors } from '@/constants/theme';
24 24
import { ScaleSlider } from '@/components/ui/ScaleSlider';
25 25
import { MoodComponentCard } from '@/components/ui/MoodComponentCard';
26 26
import { Ionicons } from '@expo/vector-icons';
27 -
import { useThemeColor } from '@/hooks/use-theme-color';
27 +
import { useThemeColor, useCreateMoodEntry } from '@/hooks';
28 28
import {
29 29
useMoodEntryStore,
30 -
} from '@/stores/moodEntry';
30 +
} from '@/stores';
31 31
32 32
import {
33 33
MOODS,
34 34
getMood
35 35
} from '@/constants/moods';
57 57
setSelectedMood,
58 58
components,
59 59
reset
60 60
} = useMoodEntryStore();
61 61
62 +
const createMoodEntry = useCreateMoodEntry();
63 +
62 64
useEffect(() => {
63 65
setSelectedMood(getMood(initialMood));
64 66
}, [initialMood]);
65 67
66 68
useEffect(() => {
77 79
annotation: annotation,
78 80
moodComponents: components.map((c) => ({
79 81
component: c.id.toUpperCase(),
80 82
intensity: intensityToValue(c.intensity)
81 83
}))
82 -
}
84 +
};
83 85
84 -
const result = await apiClient.createMoodEntry(data)
86 +
await createMoodEntry.mutateAsync(data)
85 87
86 -
router.push("/")
88 +
reset();
89 +
router.replace("/")
87 90
}
88 91
89 92
const editComponents = () => {
90 93
router.push("/entry/mood-components")
91 94
}
199 202
},
200 203
resumeCard: {
201 204
padding: Spacing.cardGap,
202 205
}
203 206
});
frontend/app/_layout.tsx
1 1
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
2 2
import { Stack } from 'expo-router';
3 -
import { useColorScheme } from 'react-native'
3 +
import { useColorScheme } from 'react-native';
4 +
import { QueryClientProvider } from '@tanstack/react-query';
5 +
import { queryClient } from '@/lib/queryClient';
4 6
5 7
import { AuthProvider } from '@/context/AuthContext';
6 8
7 9
export const unstable_settings = {
8 10
initialRouteName: '(tabs)',
10 12
11 13
export default function RootLayout() {
12 14
const colorScheme = useColorScheme();
13 15
14 16
return (
15 -
<ThemeProvider value={DefaultTheme}>
16 -
<AuthProvider>
17 -
<Stack>
18 -
<Stack.Screen name="index" options={{ headerShown: false }} />
19 -
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
20 -
<Stack.Screen
21 -
name="auth"
22 -
options={{
23 -
headerShown: false,
24 -
presentation: 'modal'
25 -
}}
26 -
/>
17 +
<QueryClientProvider client={queryClient}>
18 +
<ThemeProvider value={DefaultTheme}>
19 +
<AuthProvider>
20 +
<Stack>
21 +
<Stack.Screen name="index" options={{ headerShown: false }} />
22 +
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
23 +
<Stack.Screen
24 +
name="auth"
25 +
options={{
26 +
headerShown: false,
27 +
presentation: 'modal'
28 +
}}
29 +
/>
27 30
28 -
<Stack.Screen
29 -
name="profile"
30 -
options={{
31 -
title: 'Editar perfil',
32 -
}}
33 -
/>
31 +
<Stack.Screen
32 +
name="profile"
33 +
options={{
34 +
title: 'Editar perfil',
35 +
}}
36 +
/>
34 37
35 -
<Stack.Screen
36 -
name="entry/mood-components"
37 -
options={{
38 -
presentation: 'formSheet',
39 -
title: 'Editar Componentes',
40 -
sheetAllowedDetents: [0.25, 0.5, 1],
41 -
sheetInitialDetentIndex: 1
42 -
}}
43 -
/>
38 +
<Stack.Screen
39 +
name="entry/mood-components"
40 +
options={{
41 +
presentation: 'formSheet',
42 +
title: 'Editar Componentes',
43 +
sheetAllowedDetents: [0.25, 0.5, 1],
44 +
sheetInitialDetentIndex: 1
45 +
}}
46 +
/>
44 47
45 -
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
46 -
</Stack>
47 -
</AuthProvider>
48 -
</ThemeProvider>
48 +
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
49 +
</Stack>
50 +
</AuthProvider>
51 +
</ThemeProvider>
52 +
</QueryClientProvider>
49 53
);
50 54
}
/dev/null → frontend/hooks/index.ts
1 +
// Legacy shit from react native
2 +
export {
3 +
useThemeColor
4 +
} from '@/hooks/use-theme-color';
5 +
6 +
export {
7 +
useColorScheme
8 +
} from '@/hooks/use-color-scheme';
9 +
10 +
// Custom stuff
11 +
export {
12 +
moodKeys,
13 +
useMoodEntries,
14 +
useMoodEntriesInfinite,
15 +
useMoodEntry,
16 +
useCreateMoodEntry,
17 +
useDeleteMoodEntry
18 +
} from '@/hooks/useMoodEntries.queries';
/dev/null → frontend/hooks/useMoodEntries.queries.ts
1 +
import {
2 +
useQuery,
3 +
useMutation,
4 +
useQueryClient,
5 +
useInfiniteQuery,
6 +
} from '@tanstack/react-query';
7 +
8 +
import { apiClient, MoodEntry, CreateMoodEntryPayload } from '@/lib/api';
9 +
10 +
export const moodKeys = {
11 +
all: () => ['mood-entries'] as const,
12 +
lists: () => [...moodKeys.all(), 'list'] as const,
13 +
list: (filters?: MoodEntryFilters) => [...moodKeys.lists(), filters] as const,
14 +
detail: (id: string) => [...moodKeys.all(), 'detail', id] as const,
15 +
};
16 +
17 +
interface MoodEntryFilters {
18 +
from?: string; // ISO date
19 +
to?: string; // ISO date
20 +
limit?: number;
21 +
}
22 +
23 +
// Queries
24 +
25 +
// Fetch a paginated list of mood entries.
26 +
// On cold start, returns cached data instantly then revalidates.
27 +
export const useMoodEntries = (filters?: MoodEntryFilters) => {
28 +
return useQuery({
29 +
queryKey: moodKeys.list(filters),
30 +
queryFn: () => apiClient.getMoodEntries(filters),
31 +
});
32 +
}
33 +
34 +
// Infinite scroll version for the history tab
35 +
export const useMoodEntriesInfinite = (limit = 20) => {
36 +
return useInfiniteQuery({
37 +
queryKey: [...moodKeys.lists(), 'infinite'],
38 +
queryFn: ({ pageParam = 1 }) =>
39 +
apiClient.getMoodEntries({ page: pageParam, limit }),
40 +
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
41 +
initialPageParam: 1,
42 +
});
43 +
}
44 +
45 +
// Single entry detail
46 +
export const useMoodEntry = (id: string) => {
47 +
return useQuery({
48 +
queryKey: moodKeys.detail(id),
49 +
queryFn: () => apiClient.getMoodEntry(id),
50 +
enabled: !!id,
51 +
});
52 +
}
53 +
54 +
// MUTATIONS
55 +
56 +
// Create a new mood entry
57 +
// Optimistically adds to cache, rolls back on failure
58 +
export const useCreateMoodEntry = () => {
59 +
const queryClient = useQueryClient();
60 +
61 +
return useMutation({
62 +
mutationFn: (payload: CreateMoodEntryPayload) =>
63 +
apiClient.createMoodEntry(payload),
64 +
65 +
// Optimistic update so that the UI reflects the new entry immediately
66 +
onMutate: async (payload) => {
67 +
// Cancel any in-flight refetches so they don't overwrite our optimistic update
68 +
await queryClient.cancelQueries({ queryKey: moodKeys.lists() });
69 +
70 +
// Snapshot current cache so we can roll back
71 +
const previous = queryClient.getQueryData(moodKeys.list());
72 +
73 +
// Inject a temporary optimistic record
74 +
// TODO: might as well create a shared attrs that we're keeping it
75 +
queryClient.setQueryData(moodKeys.list(), (old: MoodEntry[] = []) => [
76 +
{
77 +
...payload,
78 +
id: `temp-${Date.now()}`,
79 +
createdAt: new Date().toISOString(),
80 +
_optimistic: true,
81 +
},
82 +
...old,
83 +
]);
84 +
85 +
return { previous };
86 +
},
87 +
88 +
// On error, roll back to the snapshot
89 +
onError: (_err, _payload, context) => {
90 +
if (context?.previous) {
91 +
queryClient.setQueryData(moodKeys.list(), context.previous);
92 +
}
93 +
},
94 +
95 +
// On success, replace optimistic record with real one from server
96 +
onSuccess: () => {
97 +
queryClient.invalidateQueries({ queryKey: moodKeys.lists() });
98 +
},
99 +
});
100 +
}
101 +
102 +
// Delete a mood entry with optimistic removal
103 +
export const useDeleteMoodEntry = () => {
104 +
const queryClient = useQueryClient();
105 +
106 +
return useMutation({
107 +
mutationFn: (id: string) => apiClient.deleteMoodEntry(id),
108 +
109 +
onMutate: async (id) => {
110 +
await queryClient.cancelQueries({ queryKey: moodKeys.lists() });
111 +
const previous = queryClient.getQueryData(moodKeys.list());
112 +
113 +
queryClient.setQueryData(moodKeys.list(), (old: MoodEntry[] = []) =>
114 +
old.filter((e) => e.id !== id)
115 +
);
116 +
117 +
return { previous };
118 +
},
119 +
120 +
onError: (_err, _id, context) => {
121 +
if (context?.previous) {
122 +
queryClient.setQueryData(moodKeys.list(), context.previous);
123 +
}
124 +
},
125 +
126 +
onSuccess: () => {
127 +
queryClient.invalidateQueries({ queryKey: moodKeys.lists() });
128 +
},
129 +
});
130 +
}
frontend/lib/api/client.ts
1 1
import * as SecureStore from 'expo-secure-store';
2 2
import {
3 3
User,
4 4
AuthResponse,
5 5
VerifyTokenResponse,
156 156
return await this.request(`/moods`, {
157 157
method: 'POST',
158 158
body: JSON.stringify({ mood })
159 159
})
160 160
}
161 +
162 +
async getMoodEntries(params?: {
163 +
from?: string;
164 +
to?: string;
165 +
page?: number;
166 +
limit?: number;
167 +
}): Promise<PaginatedResponse<MoodEntry>> {
168 +
const query = new URLSearchParams();
169 +
if (params?.from) query.set('from', params.from);
170 +
if (params?.to) query.set('to', params.to);
171 +
if (params?.page) query.set('page', String(params.page));
172 +
if (params?.limit) query.set('limit', String(params.limit));
173 +
174 +
const qs = query.toString();
175 +
return this.request(`/moods${qs ? `?${qs}` : ''}`);
176 +
}
177 +
178 +
async getMoodEntry(id: string): Promise<MoodEntry> {
179 +
return this.request(`/moods/${id}`);
180 +
}
161 181
}
162 182
163 183
export const apiClient = new ApiClient();
/dev/null → frontend/lib/queryClient.ts
1 +
import { QueryClient } from '@tanstack/react-query';
2 +
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
3 +
import { persistQueryClient } from '@tanstack/react-query-persist-client';
4 +
import { createMMKV } from 'react-native-mmkv';
5 +
6 +
export const storage = new createMMKV({ id: 'query-cache' });
7 +
8 +
const mmkvStorage = {
9 +
getItem: (key: string) => storage.getString(key) ?? null,
10 +
setItem: (key: string, value: string) => storage.set(key, value),
11 +
removeItem: (key: string) => storage.delete(key)
12 +
};
13 +
14 +
export const queryClient = new QueryClient({
15 +
defaultOptions: {
16 +
queries: {
17 +
// Show data by 5min before it is considered stale
18 +
staleTime: 1000 * 60 * 5,
19 +
// Keep inactive queries for 10min
20 +
gcTime: 1000 * 60 * 10,
21 +
// Retry on flaky connections
22 +
retry: 1,
23 +
// Don't switch just because the user switched apps
24 +
refetchOnWindowFocus: false,
25 +
},
26 +
mutations: {
27 +
retry: 1
28 +
}
29 +
}
30 +
});
31 +
32 +
const persister = createSyncStoragePersister({
33 +
storage: mmkvStorage,
34 +
// We don't want to hammer mmkv every keystroke
35 +
throttleTime: 1000
36 +
});
37 +
38 +
persistQueryClient({
39 +
queryClient,
40 +
persister,
41 +
// Queries survive for 24h on disk if not invalidated
42 +
maxAge: 1000 * 60 * 60 * 24
43 +
});
/dev/null → frontend/stores/index.ts
1 +
export {
2 +
useMoodEntryStore
3 +
} from '@/stores/moodEntry';