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/, andsrc/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
anytype — all types must be explicitly defined. - •Never use
alert()orwindow.confirm()— usetoastfromsonnerfor notifications andConfirmModalfor confirmation dialogs. - •Always use
useCallbackfor 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
handleResponsemethod. - •Always use
getHeaders(true)fromapiHelpersfor authenticated requests. - •Always check
result.sucessobefore 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
/** {Entity} Types — Types for the {entity description} system */
// Enums (if needed)
export enum {Entity}StatusEnum {
Unknown = 0,
Active = 1,
Inactive = 2,
}
// Core Entity
/** Main {entity} information */
export interface {Entity}Info {
{entity}Id: number;
name: string;
// ... other fields with JSDoc comments
}
// DTOs
/** Data required to create a new {entity} (no ID) */
export interface {Entity}InsertInfo {
name: string;
// ... fields required for creation
}
/** Data required to update an existing {entity} (includes ID) */
export interface {Entity}UpdateInfo {
{entity}Id: number;
name: string;
// ... fields required for update
}
// API Response Types — always include sucesso, mensagem, erros (Portuguese keys)
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;
}
/** Status-only operation result (import from existing types file if already defined) */
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
StatusResultalready 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;
}
/** {Entity} Service — Manages all API operations related to {entities} */
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();
}
/** List all {entities} */
async list(): Promise<{Entity}ListResult> {
const response = await fetch(`${API_BASE}/list`, { headers: getHeaders(true) });
return this.handleResponse<{Entity}ListResult>(response);
}
/** Get a {entity} by ID */
async getById(id: number): Promise<{Entity}GetResult> {
const response = await fetch(`${API_BASE}/getbyid/${id}`, { headers: getHeaders(true) });
return this.handleResponse<{Entity}GetResult>(response);
}
/** Create a new {entity} */
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);
}
/** Update an existing {entity} */
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);
}
/** Delete a {entity} by ID */
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
handleResponsethat checks 401 and callsonUnauthorized - •
API_BASEusesVITE_GOBLIN_API_URLenv var withhttp://localhost:4041fallback - •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 {
// State
{entities}: {Entity}Info[];
selected{Entity}: {Entity}Info | null;
loading: boolean;
error: string | null;
// API Methods (return API results for caller to check sucesso)
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>;
// State Management (return void, handle loading/error internally)
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;
};
// --- API Methods (direct service wrappers) ---
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}]);
// --- State Management ---
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:
- •
handleErrorsets error state AND re-throws (returnsnever) - •After insert: reload full list. After update: update item in local state. After delete: remove from local state.
- •Check
result.sucessobefore 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';
/** Custom hook to access the {Entity} context. Throws if used outside {Entity}Provider. */
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
// Add import at the top with other provider imports
import { {Entity}Provider } from './contexts/{Entity}Context.tsx'
// Add to the provider chain based on dependencies:
// - If it depends on AuthContext → must be inside AuthProvider
// - If it depends on GoblinContext → must be inside GoblinProvider
// - If independent → place near the end, before App
Nesting rules:
- •
AuthProvideris 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
- • Types file at
src/types/{entity}.tswith all interfaces - • Service file at
src/services/{entity}Service.tswith class pattern - • Context file at
src/contexts/{Entity}Context.tsxwith provider - • Hook file at
src/hooks/use{Entity}.tswith null-check - • Provider imported and added to
src/main.tsxin correct nesting position - • All API response types include
sucesso,mensagem,errosfields - • All context methods use
useCallback - •
handleErrorpattern: sets error state + re-throws - • No
anytypes, noalert(), nowindow.confirm() - • Service uses
getHeaders(true)andVITE_GOBLIN_API_URLenv var
Common Gotchas
- •StatusResult may already exist: Check
src/types/map.tsor other type files before redefining. Import from existing file if available. - •Entity ID naming: Always use
{entity}Id(camelCase), not{entity}_idorid. - •API response keys are Portuguese:
sucesso(notsuccess),mensagem(notmessage),erros(noterrors). - •Service constructor config: Always include
onUnauthorizedcallback in config interface. - •Provider nesting order matters: A context cannot use hooks from providers nested inside it.