frontend: flesh and rig react-query + mmkv

Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago c6d61c3d0df216df84ee31bc55dd02e9d0073fce
Parents: 9178180
7 file(s) changed
  • frontend/app/(tabs)/new/entry.tsx +8 -5
  • frontend/app/_layout.tsx +36 -32
  • frontend/hooks/index.ts +18 -0
  • frontend/hooks/useMoodEntries.queries.ts +130 -0
  • frontend/lib/api/client.ts +20 -0
  • frontend/lib/queryClient.ts +43 -0
  • frontend/stores/index.ts +3 -0
frontend/app/(tabs)/new/entry.tsx
@@ -24,10 +24,10 @@ 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,
@@ -59,6 +59,8 @@ 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]);
@@ -79,11 +81,12 @@ 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 = () => {
frontend/app/_layout.tsx
@@ -1,6 +1,8 @@
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
@@ -12,39 +14,41 @@ 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 }
frontend/hooks/index.ts
@@ -0,0 +1,18 @@
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';
frontend/hooks/useMoodEntries.queries.ts
@@ -0,0 +1,130 @@
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
@@ -158,6 +158,26 @@ 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();
frontend/lib/queryClient.ts
@@ -0,0 +1,43 @@
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 + });
frontend/stores/index.ts
@@ -0,0 +1,3 @@
1 + export {
2 + useMoodEntryStore
3 + } from '@/stores/moodEntry';