| name | react-arch |
| description | Create the complete frontend architecture for a new entity in the React application. Generates TypeScript types, service class, context provider, custom hook, and registers the provider in main.tsx. Use this skill when the user asks to create a new entity, feature module, or domain area in the frontend. |
This skill defines the standard approach for scaffolding a complete frontend entity architecture in this project. It creates all necessary files following the established patterns: Types, Service, Context, Hook, and Provider registration.
Prerequisites
Before creating the architecture, you MUST:
- Ask the user for the entity name (e.g., "Clan", "Achievement", "Shop")
- Ask the user for the API base path (e.g., "/api/clan", "/api/achievement")
- Ask the user for the entity fields/properties or check if API documentation exists in
docs/
- Check if the entity already exists — search
src/types/, src/services/, src/contexts/, and src/hooks/ before creating anything
Creation Order
Always create files in this exact order:
- Types →
src/types/{entity}.ts
- Service →
src/services/{entity}Service.ts
- Context →
src/contexts/{Entity}Context.tsx
- Hook →
src/hooks/use{Entity}.ts
- Provider registration →
src/main.tsx
Rules
- Never use
any type — all types must be explicitly defined.
- Never use
alert() or window.confirm() — use toast from sonner for notifications and ConfirmModal for confirmation dialogs.
- Always use
useCallback for all context methods to prevent unnecessary re-renders.
- Always include
loading, error, and main data state in every context.
- Always use the class-based service pattern with private
handleResponse method.
- Always use
getHeaders(true) from apiHelpers for authenticated requests.
- Always check
result.sucesso before updating state (API responses use Portuguese keys).
- Always export both the singleton instance and the class from services.
- Always export the context as default and the provider as named export.
- Always add JSDoc comments to service methods and type interfaces.
Step 1: Create Types
File: src/types/{entity}.ts
export enum {Entity}StatusEnum {
Unknown = 0,
Active = 1,
Inactive = 2,
}
export interface {Entity}Info {
{entity}Id: number;
name: string;
}
export interface {Entity}InsertInfo {
name: string;
}
export interface {Entity}UpdateInfo {
{entity}Id: number;
name: string;
}
export interface {Entity}ListResult {
{entities}: {Entity}Info[];
sucesso: boolean;
mensagem: string | null;
erros: string[] | null;
}
export interface {Entity}GetResult {
{entity}: {Entity}Info;
sucesso: boolean;
mensagem: string | null;
erros: string[] | null;
}
export interface StatusResult {
sucesso: boolean;
mensagem: string;
erros: string[] | null;
}
Key conventions:
- API responses always have
sucesso, mensagem, erros — Portuguese keys
- Entity IDs use camelCase:
{entity}Id (e.g., clanId)
- Nullable fields use
| null, not optional ?
- Separate interfaces for Insert (no ID) and Update (with ID) DTOs
- If
StatusResult already exists in another types file, import it instead of redefining
Step 2: Create Service
File: src/services/{entity}Service.ts
import type {
{Entity}ListResult, {Entity}GetResult,
{Entity}InsertInfo, {Entity}UpdateInfo, StatusResult,
} from '../types/{entity}';
import { getHeaders } from './apiHelpers';
const API_BASE = `${import.meta.env.VITE_GOBLIN_API_URL || 'http://localhost:4041'}/api/{entity-kebab}`;
interface {Entity}ServiceConfig {
onUnauthorized?: () => void;
}
class {Entity}Service {
private config: {Entity}ServiceConfig;
constructor(config: {Entity}ServiceConfig = {}) {
this.config = config;
}
private async handleResponse<T>(response: Response): Promise<T> {
if (response.status === 401) {
this.config.onUnauthorized?.();
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Request failed');
}
return response.json();
}
async list(): Promise<{Entity}ListResult> {
const response = await fetch(`${API_BASE}/list`, { headers: getHeaders(true) });
return this.handleResponse<{Entity}ListResult>(response);
}
async getById(id: number): Promise<{Entity}GetResult> {
const response = await fetch(`${API_BASE}/getbyid/${id}`, { headers: getHeaders(true) });
return this.handleResponse<{Entity}GetResult>(response);
}
async insert(data: {Entity}InsertInfo): Promise<{Entity}GetResult> {
const response = await fetch(`${API_BASE}/insert`, {
method: 'POST', headers: getHeaders(true), body: JSON.stringify(data),
});
return this.handleResponse<{Entity}GetResult>(response);
}
async update(data: {Entity}UpdateInfo): Promise<{Entity}GetResult> {
const response = await fetch(`${API_BASE}/update`, {
method: 'PUT', headers: getHeaders(true), body: JSON.stringify(data),
});
return this.handleResponse<{Entity}GetResult>(response);
}
async delete(id: number): Promise<StatusResult> {
const response = await fetch(`${API_BASE}/delete/${id}`, {
method: 'DELETE', headers: getHeaders(true),
});
return this.handleResponse<StatusResult>(response);
}
}
export const {entity}Service = new {Entity}Service();
export default {Entity}Service;
Key conventions:
- Class-based with private
handleResponse that checks 401 and calls onUnauthorized
API_BASE uses VITE_GOBLIN_API_URL env var with http://localhost:4041 fallback
- Always use
getHeaders(true) for authenticated requests
- Export both singleton instance (camelCase) and class (PascalCase default)
Step 3: Create Context
File: src/contexts/{Entity}Context.tsx
The context wraps the service and provides state management with three method categories: API Methods (direct service wrappers returning API results), State Management (handle loading/error, update local state), and optionally Utility Methods.
import { createContext, useState, useCallback, ReactNode } from 'react';
import { {entity}Service } from '../services/{entity}Service';
import type {
{Entity}Info, {Entity}ListResult, {Entity}GetResult,
{Entity}InsertInfo, {Entity}UpdateInfo, StatusResult,
} from '../types/{entity}';
interface {Entity}ContextType {
{entities}: {Entity}Info[];
selected{Entity}: {Entity}Info | null;
loading: boolean;
error: string | null;
list{Entities}: () => Promise<{Entity}ListResult>;
get{Entity}ById: (id: number) => Promise<{Entity}GetResult>;
insert{Entity}: (data: {Entity}InsertInfo) => Promise<{Entity}GetResult>;
update{Entity}: (data: {Entity}UpdateInfo) => Promise<{Entity}GetResult>;
delete{Entity}: (id: number) => Promise<StatusResult>;
load{Entities}: () => Promise<void>;
refresh{Entities}: () => Promise<void>;
setSelected{Entity}: (item: {Entity}Info | null) => void;
clearError: () => void;
}
const {Entity}Context = createContext<{Entity}ContextType | undefined>(undefined);
export const {Entity}Provider = ({ children }: { children: ReactNode }) => {
const [{entities}, set{Entities}] = useState<{Entity}Info[]>([]);
const [selected{Entity}, setSelected{Entity}] = useState<{Entity}Info | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleError = (err: unknown): never => {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
setError(errorMsg);
throw err;
};
const list{Entities} = useCallback(async (): Promise<{Entity}ListResult> => {
try { setError(null); return await {entity}Service.list(); }
catch (err) { return handleError(err); }
}, []);
const get{Entity}ById = useCallback(async (id: number): Promise<{Entity}GetResult> => {
try { setError(null); return await {entity}Service.getById(id); }
catch (err) { return handleError(err); }
}, []);
const insert{Entity} = useCallback(async (data: {Entity}InsertInfo): Promise<{Entity}GetResult> => {
try {
setError(null);
const result = await {entity}Service.insert(data);
if (result.sucesso) await load{Entities}();
return result;
} catch (err) { return handleError(err); }
}, []);
const update{Entity} = useCallback(async (data: {Entity}UpdateInfo): Promise<{Entity}GetResult> => {
try {
setError(null);
const result = await {entity}Service.update(data);
if (result.sucesso) {
set{Entities}((prev) => prev.map((item) =>
item.{entity}Id === data.{entity}Id ? result.{entity} : item
));
if (selected{Entity}?.{entity}Id === data.{entity}Id) setSelected{Entity}(result.{entity});
}
return result;
} catch (err) { return handleError(err); }
}, [selected{Entity}]);
const delete{Entity} = useCallback(async (id: number): Promise<StatusResult> => {
try {
setError(null);
const result = await {entity}Service.delete(id);
if (result.sucesso) {
set{Entities}((prev) => prev.filter((item) => item.{entity}Id !== id));
if (selected{Entity}?.{entity}Id === id) setSelected{Entity}(null);
}
return result;
} catch (err) { return handleError(err); }
}, [selected{Entity}]);
const load{Entities} = useCallback(async (): Promise<void> => {
try {
setLoading(true);
setError(null);
const result = await {entity}Service.list();
if (result.sucesso) set{Entities}(result.{entities});
else throw new Error(result.mensagem || 'Failed to load {entities}');
} catch (err) { handleError(err); }
finally { setLoading(false); }
}, []);
const refresh{Entities} = useCallback(async () => { await load{Entities}(); }, [load{Entities}]);
const clearError = useCallback(() => { setError(null); }, []);
const value: {Entity}ContextType = {
{entities}, selected{Entity}, loading, error,
list{Entities}, get{Entity}ById, insert{Entity}, update{Entity}, delete{Entity},
load{Entities}, refresh{Entities}, setSelected{Entity}, clearError,
};
return <{Entity}Context.Provider value={value}>{children}</{Entity}Context.Provider>;
};
export default {Entity}Context;
Key conventions:
handleError sets error state AND re-throws (returns never)
- After insert: reload full list. After update: update item in local state. After delete: remove from local state.
- Check
result.sucesso before updating state
- Export provider as named export, context as default export
Step 4: Create Hook
File: src/hooks/use{Entity}.ts
import { useContext } from 'react';
import {Entity}Context from '../contexts/{Entity}Context';
export const use{Entity} = () => {
const context = useContext({Entity}Context);
if (!context) throw new Error('use{Entity} must be used within a {Entity}Provider');
return context;
};
export default use{Entity};
Only add computed values/derived state if the user specifically requests it.
Step 5: Register Provider in main.tsx
File: src/main.tsx
import { {Entity}Provider } from './contexts/{Entity}Context.tsx'
Nesting rules:
AuthProvider is always the outermost (all contexts depend on auth)
- Place the new provider as close to
<App /> as possible unless it has dependents
- If other contexts will depend on this one, place it above those contexts
- Keep related contexts grouped (economy, gameplay, admin, etc.)
Current provider order:
AuthProvider → FinanceProvider → GoblinProvider → TeamProvider →
GoboxProvider → AuctionProvider → GLogProvider → QuestProvider →
ItemProvider → ItemClaimProvider → MiningProvider → MapProvider →
TerritoryProvider → TerritoryEnemyProvider → ArenaProvider → App
Naming Convention Reference
| Item | Convention | Example |
|---|
| Types file | src/types/{entity}.ts | src/types/clan.ts |
| Service file | src/services/{entity}Service.ts | src/services/clanService.ts |
| Service class | {Entity}Service | ClanService |
| Service instance | {entity}Service | clanService |
| Context file | src/contexts/{Entity}Context.tsx | src/contexts/ClanContext.tsx |
| Provider | {Entity}Provider | ClanProvider |
| Hook file | src/hooks/use{Entity}.ts | src/hooks/useClan.ts |
| Hook function | use{Entity} | useClan |
| Entity ID field | {entity}Id | clanId |
| List/Get result | {Entity}ListResult / {Entity}GetResult | ClanListResult / ClanGetResult |
| Insert/Update DTO | {Entity}InsertInfo / {Entity}UpdateInfo | ClanInsertInfo / ClanUpdateInfo |
Verification Checklist
Common Gotchas
- StatusResult may already exist: Check
src/types/map.ts or other type files before redefining. Import from existing file if available.
- Entity ID naming: Always use
{entity}Id (camelCase), not {entity}_id or id.
- API response keys are Portuguese:
sucesso (not success), mensagem (not message), erros (not errors).
- Service constructor config: Always include
onUnauthorized callback in config interface.
- Provider nesting order matters: A context cannot use hooks from providers nested inside it.