| name | react-spa-patterns |
| description | React SPA patterns for Frappe-backed applications. Reference for TanStack Query, Axios, Jotai, shadcn/ui, Tailwind, Refine v4, Mantine v5, React Router, Vite config, and Frappe API integration in the Unity Parent App and Walsh Admin Portal. |
React SPA Patterns for Frappe
Quick reference for building React SPAs that integrate with a Frappe backend.
Project Overview
| App | Path | Stack |
|---|
| Unity Parent App | apps/unity_parent_app/new_frontend/ | React 18 + Vite 6 + shadcn/ui + Tailwind + TanStack Query v5 + Jotai + Axios |
| Walsh Admin Portal | apps/edu_quality/walsh/ | React 18 + Vite 5 + Refine v4 + Mantine v5 + React Query v3 |
Frappe API Integration
Axios (Parent App)
import axiosInstance from '@/utils/axiosInstance';
const res = await axiosInstance.post(
'/api/method/app.module.function',
{ student_id: 'STU-001' }
);
const data = res.data.message;
const res = await axiosInstance.get('/api/resource/Student/STU-001');
const student = res.data.data;
Refine Data Provider (Walsh)
import { useList, useOne, useCreate, useUpdate, useDelete } from "@refinedev/core";
const { data } = useList({
resource: "Student",
filters: [{ field: "enabled", operator: "eq", value: 1 }],
pagination: { pageSize: 50 },
meta: { dataProviderName: "default" },
});
const { mutate: create } = useCreate();
create({ resource: "Student", values: { ... } });
const res = await dataProvider.custom({
url: '/api/method/edu_quality.api.module.function',
method: 'post',
payload: { param: value },
});
TanStack Query Patterns
Parent App (v5)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const { data, isLoading, error } = useQuery({
queryKey: ['students', academicYear],
queryFn: () =>
axiosInstance.post('/api/method/...', { academic_year: academicYear })
.then(r => r.data.message),
staleTime: 5 * 60 * 1000,
enabled: !!academicYear,
});
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: (data) => axiosInstance.post('/api/method/...', data).then(r => r.data.message),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['students'] }),
});
Walsh (v3)
import { useQuery, useMutation } from 'react-query';
const { data } = useQuery(
['key', dep],
() => fetch('/api/method/...').then(r => r.json()).then(r => r.message),
{ staleTime: 60_000 }
);
State Management
Jotai (Parent App)
import { atom } from 'jotai';
export const activeStudentAtom = atom<string | null>(null);
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
const [student, setStudent] = useAtom(activeStudentAtom);
const student = useAtomValue(activeStudentAtom);
const setStudent = useSetAtom(activeStudentAtom);
Routing
React Router v6 (Parent App)
import { BrowserRouter, Routes, Route } from 'react-router-dom';
<BrowserRouter basename="/parent-app">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/transport" element={<Transport />} />
</Routes>
</BrowserRouter>
import { useNavigate, useParams } from 'react-router-dom';
const navigate = useNavigate();
navigate('/transport?active_student=STU-001');
Refine Router (Walsh)
resources={[
{
name: "notices",
list: "/notices",
create: "/notices/create",
edit: "/notices/edit/:id",
meta: { dataProviderName: "notices" },
},
]}
UI Components
shadcn/ui + Tailwind (Parent App)
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { cva } from "class-variance-authority";
const buttonVariants = cva("base-classes", {
variants: { size: { sm: "...", lg: "..." } },
});
Mantine v5 (Walsh)
import { Button, TextInput, Select, Stack, Group, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
const [opened, { open, close }] = useDisclosure(false);
notifications.show({ title: "Saved", message: "Record updated", color: "green" });
Real-Time (Parent App Only)
import { subscribeToRoom, onRealtimeEvent, offRealtimeEvent, unsubscribeFromRoom } from '@/utils/frappeSocket';
subscribeToRoom(`transport_journey_${journeyId}`);
const handler = (data: unknown) => { };
onRealtimeEvent('transport_gps_update', handler);
offRealtimeEvent('transport_gps_update', handler);
unsubscribeFromRoom(`transport_journey_${journeyId}`);
Build & Deploy
cd apps/unity_parent_app/new_frontend
npm run dev
npm run build
cd apps/edu_quality/walsh
yarn dev
yarn build
bench --site <site> clear-cache
bench build --app <app>
Common Patterns
Protected Route (Parent App)
import { Navigate } from 'react-router-dom';
import { isLoggedIn } from '@/utils/cookies';
const ProtectedRoute = ({ children }) =>
isLoggedIn() ? children : <Navigate to="/login" replace />;
Error Boundary
import ErrorBoundary from '@/components/ErrorBoundary';
<ErrorBoundary fallback={<ErrorPage />}>
<MyPage />
</ErrorBoundary>
Frappe File Upload
const formData = new FormData();
formData.append('file', file);
formData.append('doctype', 'Student');
formData.append('docname', studentId);
const res = await axiosInstance.post('/api/method/upload_file', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});