State Management
Overview
Frontend CLM menggunakan kombinasi beberapa pendekatan state management:
- React Context - Global app state (auth, tenant)
- TanStack Query - Server state (API data)
- localStorage - Persistence
- React State - Local component state
AppContext (Global State)
Context Provider
// src/lib/app-context.tsx
type AppState = {
tenantId: string;
setTenantId: (t: string) => void;
roleId: RoleId;
setRoleId: (r: RoleId) => void;
isAuthed: boolean;
setAuthed: (v: boolean) => void;
userName: string;
setUserName: (n: string) => void;
userRoles: string[];
setUserRoles: (r: string[]) => void;
tenantIdClaim: string;
setTenantIdClaim: (t: string) => void;
tenantName: string;
setTenantName: (n: string) => void;
};
const Ctx = createContext<AppState | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [tenantId, setTenantIdState] = useState<string>("all");
const [roleId, setRoleId] = useState<RoleId>("executive");
const [isAuthed, setAuthed] = useState(false);
const [userName, setUserName] = useState("Demo User");
const [userRoles, setUserRoles] = useState<string[]>([]);
const [tenantIdClaim, setTenantIdClaim] = useState<string>("");
const [tenantName, setTenantName] = useState<string>("");
// Wrap setTenantId to sync with HTTP layer
function setTenantId(t: string) {
setActiveTenant(t);
setTenantIdState(t);
}
// Restore from localStorage
useEffect(() => {
const raw = localStorage.getItem(KEY);
if (raw) {
const s = JSON.parse(raw);
if (s.tenantId) {
setActiveTenant(s.tenantId);
setTenantIdState(s.tenantId);
}
if (s.roleId) setRoleId(s.roleId);
if (typeof s.isAuthed === 'boolean') setAuthed(s.isAuthed);
if (s.userName) setUserName(s.userName);
if (Array.isArray(s.userRoles)) setUserRoles(s.userRoles);
}
}, []);
// Persist to localStorage
useEffect(() => {
localStorage.setItem(KEY, JSON.stringify({
tenantId, roleId, isAuthed, userName, userRoles, tenantIdClaim, tenantName
}));
}, [tenantId, roleId, isAuthed, userName, userRoles, tenantIdClaim, tenantName]);
return (
<Ctx.Provider value={{
tenantId, setTenantId, roleId, setRoleId,
isAuthed, setAuthed, userName, setUserName,
userRoles, setUserRoles, tenantIdClaim, setTenantIdClaim,
tenantName, setTenantName
}}>
{children}
</Ctx.Provider>
);
}
export function useApp() {
const v = useContext(Ctx);
if (!v) throw new Error("useApp must be used within AppProvider");
return v;
}
Usage
function MyComponent() {
const {
tenantId, setTenantId,
roleId, setRoleId,
isAuthed, setAuthed,
userName, setUserName
} = useApp();
return (
<div>
<p>Welcome, {userName}</p>
<p>Tenant: {tenantId}</p>
<p>Role: {roleId}</p>
</div>
);
}
TanStack Query (Server State)
Query Client Setup
// src/app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppProvider>
<RouterProvider router={router} />
</AppProvider>
</QueryClientProvider>
);
}
Custom Query Hooks
// src/api/logs.ts
export function useLogSearch(params: LogSearchParams) {
return useQuery({
queryKey: ['logs', 'search', params],
queryFn: () => fetchJson('/api/v1/logs/list/paginate', {
method: 'POST',
body: JSON.stringify(params),
}),
});
}
export function useLogFields() {
return useQuery({
queryKey: ['logs', 'fields'],
queryFn: () => fetchJson('/api/v1/logs/fields'),
});
}
export function useLogHistogram(params: HistogramParams) {
return useQuery({
queryKey: ['logs', 'histogram', params],
queryFn: () => fetchJson('/api/v1/logs/histogram', {
method: 'POST',
body: JSON.stringify(params),
}),
});
}
Mutations
// src/api/rbac.ts
export function useCreateTenant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTenantRequest) =>
fetchJson('/api/tenants', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenants'] });
toast.success('Tenant created');
},
onError: (error) => {
toast.error('Failed to create tenant');
},
});
}
localStorage Persistence
App State Persistence
const KEY = "tssmartlog-state-v1";
// Restore on mount
useEffect(() => {
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const s = JSON.parse(raw);
if (s.tenantId) setTenantId(s.tenantId);
if (s.roleId) setRoleId(s.roleId);
if (typeof s.isAuthed === 'boolean') setAuthed(s.isAuthed);
if (s.userName) setUserName(s.userName);
if (Array.isArray(s.userRoles)) setUserRoles(s.userRoles);
}
} catch {}
}, []);
// Persist on change
useEffect(() => {
try {
localStorage.setItem(KEY, JSON.stringify({
tenantId, roleId, isAuthed, userName, userRoles
}));
} catch {}
}, [tenantId, roleId, isAuthed, userName, userRoles]);
Token Persistence
// src/api/http.ts
const TOKEN_KEY = "clm_access_token";
function getAccessToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
export function setAccessToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearAccessToken() {
localStorage.removeItem(TOKEN_KEY);
}
Local Component State
useState
function SearchPage() {
const [query, setQuery] = useState('');
const [timeRange, setTimeRange] = useState('24h');
const [filters, setFilters] = useState<Filter[]>([]);
const [page, setPage] = useState(1);
return (
<div>
<SearchBar
query={query}
onQueryChange={setQuery}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
<FilterPanel filters={filters} onFilterChange={setFilters} />
<LogResults query={query} timeRange={timeRange} filters={filters} page={page} />
<Pagination page={page} onPageChange={setPage} />
</div>
);
}
useReducer
type State = {
query: string;
timeRange: string;
filters: Filter[];
page: number;
loading: boolean;
error: string | null;
};
type Action =
| { type: 'SET_QUERY'; payload: string }
| { type: 'SET_TIME_RANGE'; payload: string }
| { type: 'SET_FILTERS'; payload: Filter[] }
| { type: 'SET_PAGE'; payload: number }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'RESET' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_QUERY':
return { ...state, query: action.payload, page: 1 };
case 'SET_TIME_RANGE':
return { ...state, timeRange: action.payload, page: 1 };
case 'SET_FILTERS':
return { ...state, filters: action.payload, page: 1 };
case 'SET_PAGE':
return { ...state, page: action.payload };
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload };
case 'RESET':
return initialState;
default:
return state;
}
}
function SearchPage() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<SearchBar
query={state.query}
onQueryChange={(q) => dispatch({ type: 'SET_QUERY', payload: q })}
/>
</div>
);
}
State Flow Diagram
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ AppContext │ │ TanStack │ │ localStorage│
│ (Global) │ │ Query (API) │ │ (Persistent) │
│ │ │ │ │ │
│ • Auth state │ │ • Log data │ │ • App state │
│ • Tenant │ │ • Alert data │ │ • Token │
│ • Role │ │ • Dashboard │ │ • User pref │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└────────────────────┼────────────────────┘
│
▼
┌──────────────┐
│ React UI │
│ │
│ • Components │
│ • Pages │
└──────────────┘
Best Practices
1. Separate Server and Client State
// ✅ Good: Server state via TanStack Query
const { data, isLoading, error } = useLogSearch(params);
// ❌ Bad: Server state in useState
const [logs, setLogs] = useState([]);
useEffect(() => {
fetchLogs().then(setLogs);
}, []);
2. Use Custom Hooks for Reusable Logic
// ✅ Good: Custom hook
function useAuth() {
const { isAuthed, setAuthed, userName, setUserName } = useApp();
const login = async (username: string, password: string) => {
const response = await loginApi(username, password);
setAuthed(true);
setUserName(response.user.username);
};
const logout = () => {
setAuthed(false);
setUserName('');
clearAccessToken();
};
return { isAuthed, login, logout, userName };
}
3. Minimize Re-renders
// ✅ Good: Memoized components
const LogItem = React.memo(({ log }: { log: LogEntry }) => {
return <div>{log.message}</div>;
});
// ✅ Good: Memoized callbacks
const handleSearch = useCallback((query: string) => {
setQuery(query);
}, []);
Next Steps
- Components - Component documentation
- Routing - Route configuration
- i18n - Internationalization