oneie
Version:
Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.
1,914 lines (1,551 loc) • 57 kB
Markdown
---
title: 2 4 React Hooks
dimension: things
category: features
tags: ai, architecture, backend, connections, events, frontend, ontology, things
related_dimensions: connections, events, knowledge, people
scope: global
created: 2025-11-03
updated: 2025-11-03
version: 1.0.0
ai_context: |
This document is part of the things dimension in the features category.
Location: one/things/features/2-4-react-hooks.md
Purpose: Documents feature 2-4: react hooks layer
Related dimensions: connections, events, knowledge, people
For AI agents: Read this to understand 2 4 react hooks.
---
# Feature 2-4: React Hooks Layer
**Feature ID:** `feature_2_4_react_hooks`
**Plan:** `plan_2_backend_agnostic_frontend`
**Owner:** Frontend Specialist
**Status:** Complete Specification
**Priority:** P1 (High - Frontend API)
**Effort:** 3 days
**Dependencies:** Feature 2-1 (DataProvider Interface)
---
## 1. Complete Technical Specification
### Overview
The React Hooks Layer provides a declarative, type-safe API for frontend components to interact with the 6-dimension ontology through any backend provider. This layer wraps the DataProvider interface in React hooks that match Convex's ergonomics while remaining backend-agnostic.
### Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ REACT COMPONENTS │
│ - Use hooks: useThings, useConnections, useEvents │
│ - Declarative data fetching │
│ - Automatic loading/error states │
└─────────────────────┬───────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────────┐
│ REACT HOOKS LAYER │
│ - useThings: Query entities with filters │
│ - useThing: Get single entity │
│ - useCreateThing: Create entity mutation │
│ - useUpdateThing: Update entity mutation │
│ - useDeleteThing: Delete entity mutation │
│ - useConnections: Query relationships │
│ - useCreateConnection: Create relationship │
│ - useEvents: Query event stream │
│ - useLogEvent: Log event mutation │
│ - useKnowledge: Search knowledge │
│ - useCurrentUser: Get authenticated user │
└─────────────────────┬───────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────────┐
│ DATA PROVIDER INTERFACE │
│ - Abstract backend operations │
│ - ConvexProvider, SupabaseProvider, etc. │
└─────────────────────────────────────────────────────────────┘
```
### Core Hooks
#### Query Hooks (Read Operations)
1. **useThings** - List entities by type/filters
2. **useThing** - Get single entity by ID
3. **useConnections** - Query relationships
4. **useConnection** - Get single connection
5. **useEvents** - Query event stream
6. **useEvent** - Get single event
7. **useKnowledge** - Search knowledge items
8. **useOrganization** - Get current organization
9. **useCurrentUser** - Get authenticated user
10. **usePeople** - List people in organization
#### Mutation Hooks (Write Operations)
1. **useCreateThing** - Create new entity
2. **useUpdateThing** - Update existing entity
3. **useDeleteThing** - Delete entity
4. **useCreateConnection** - Create relationship
5. **useUpdateConnection** - Update relationship
6. **useDeleteConnection** - Delete relationship
7. **useLogEvent** - Log new event
8. **useCreateKnowledge** - Create knowledge item
### Hook API Pattern
All query hooks follow this pattern:
```typescript
const {
data, // Query result (null during loading)
loading, // Boolean loading state
error, // Error object or null
refetch, // Function to manually refetch
} = useHookName(args, options);
```
All mutation hooks follow this pattern:
```typescript
const {
mutate, // Async mutation function
loading, // Boolean loading state
error, // Error object or null
reset, // Clear error state
} = useMutationName(options);
```
### Type Safety
Full TypeScript cycle throughout:
```typescript
// Type cycle from arguments
const { data: courses } = useThings({ type: "course" });
// courses: Thing[] | null
const { data: course } = useThing(courseId);
// course: Thing | null
const { mutate: createCourse } = useCreateThing();
// createCourse: (args: CreateThingArgs) => Promise<Thing>
```
### Real-Time Subscriptions
Query hooks support optional real-time updates:
```typescript
const { data, loading, error } = useThings(
{ type: "course", status: "published" },
{
realtime: true, // Subscribe to updates
refetchInterval: 0, // Disable polling (use subscriptions)
},
);
// data automatically updates when backend changes
```
### Error Handling
Hooks expose structured errors with `_tag` pattern:
```typescript
const { data, error } = useThings({ type: 'course' });
if (error) {
switch (error._tag) {
case 'NotFoundError':
return <div>No courses found</div>;
case 'UnauthorizedError':
return <div>Access denied</div>;
case 'NetworkError':
return <div>Connection lost</div>;
default:
return <div>Unknown error: {error.message}</div>;
}
}
```
### Loading States
Granular loading state management:
```typescript
const { data, loading } = useThings({ type: 'course' });
if (loading) {
return <Skeleton count={3} />; // Show skeleton UI
}
// Data is guaranteed non-null after loading
return <div>{data.map(course => <CourseCard course={course} />)}</div>;
```
---
## 2. Ontology Mapping (All 6 Dimensions)
### 1. Organizations Dimension
```typescript
/**
* useOrganization - Get current organization
*/
export function useOrganization(
organizationId?: Id<"organizations">,
): QueryResult<Organization> {
const provider = useDataProvider();
const currentOrgId = organizationId ?? provider.currentOrganizationId;
return useQuery(
["organization", currentOrgId],
() => provider.organizations.get(currentOrgId),
{ enabled: !!currentOrgId },
);
}
/**
* useOrganizations - List all organizations (platform owner only)
*/
export function useOrganizations(
filter?: OrganizationFilter,
): QueryResult<Organization[]> {
const provider = useDataProvider();
return useQuery(["organizations", filter], () =>
provider.organizations.list(filter),
);
}
/**
* useCreateOrganization - Create organization
*/
export function useCreateOrganization(): MutationResult<Organization> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: CreateOrganizationArgs) =>
provider.organizations.create(args),
onSuccess: () => {
queryClient.invalidateQueries(["organizations"]);
},
});
}
```
### 2. People Dimension
```typescript
/**
* useCurrentUser - Get authenticated user (person)
*/
export function useCurrentUser(): QueryResult<Person> {
const provider = useDataProvider();
return useQuery(["currentUser"], () => provider.people.getCurrentUser());
}
/**
* usePerson - Get person by ID
*/
export function usePerson(personId: Id<"people">): QueryResult<Person> {
const provider = useDataProvider();
return useQuery(["person", personId], () => provider.people.get(personId), {
enabled: !!personId,
});
}
/**
* usePeople - List people in organization
*/
export function usePeople(filter: PeopleFilter): QueryResult<Person[]> {
const provider = useDataProvider();
return useQuery(["people", filter], () => provider.people.list(filter));
}
/**
* useUpdatePerson - Update person profile
*/
export function useUpdatePerson(): MutationResult<Person> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...updates }: UpdatePersonArgs) =>
provider.people.update(id, updates),
onSuccess: (person) => {
queryClient.invalidateQueries(["person", person._id]);
queryClient.invalidateQueries(["currentUser"]);
},
});
}
```
### 3. Things Dimension
```typescript
/**
* useThings - List entities by type and filters
*/
export function useThings<T extends Thing = Thing>(
filter: ThingFilter,
options?: QueryOptions,
): QueryResult<T[]> {
const provider = useDataProvider();
const { organizationId } = useOrganizationContext();
return useQuery(
["things", { ...filter, organizationId }],
() => provider.things.list({ ...filter, organizationId }),
options,
);
}
/**
* useThing - Get single entity by ID
*/
export function useThing<T extends Thing = Thing>(
thingId: Id<"things"> | null,
options?: QueryOptions,
): QueryResult<T> {
const provider = useDataProvider();
return useQuery(["thing", thingId], () => provider.things.get(thingId!), {
...options,
enabled: !!thingId,
});
}
/**
* useCreateThing - Create new entity
*/
export function useCreateThing(): MutationResult<Thing> {
const provider = useDataProvider();
const queryClient = useQueryClient();
const { organizationId } = useOrganizationContext();
return useMutation({
mutationFn: (args: CreateThingArgs) =>
provider.things.create({ ...args, organizationId }),
onSuccess: (thing) => {
// Invalidate list queries for this type
queryClient.invalidateQueries(["things", { type: thing.type }]);
},
});
}
/**
* useUpdateThing - Update existing entity
*/
export function useUpdateThing(): MutationResult<Thing> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...updates }: UpdateThingArgs) =>
provider.things.update(id, updates),
onSuccess: (thing) => {
// Update single entity cache
queryClient.setQueryData(["thing", thing._id], thing);
// Invalidate list queries
queryClient.invalidateQueries(["things", { type: thing.type }]);
},
});
}
/**
* useDeleteThing - Delete entity
*/
export function useDeleteThing(): MutationResult<void> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (thingId: Id<"things">) => provider.things.delete(thingId),
onSuccess: (_, thingId) => {
// Remove from cache
queryClient.removeQueries(["thing", thingId]);
// Invalidate all thing lists
queryClient.invalidateQueries(["things"]);
},
});
}
```
### 4. Connections Dimension
```typescript
/**
* useConnections - Query relationships
*/
export function useConnections(
filter: ConnectionFilter,
options?: QueryOptions,
): QueryResult<Connection[]> {
const provider = useDataProvider();
const { organizationId } = useOrganizationContext();
return useQuery(
["connections", { ...filter, organizationId }],
() => provider.connections.list({ ...filter, organizationId }),
options,
);
}
/**
* useConnection - Get single connection
*/
export function useConnection(
connectionId: Id<"connections"> | null,
): QueryResult<Connection> {
const provider = useDataProvider();
return useQuery(
["connection", connectionId],
() => provider.connections.get(connectionId!),
{ enabled: !!connectionId },
);
}
/**
* useCreateConnection - Create relationship
*/
export function useCreateConnection(): MutationResult<Connection> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: CreateConnectionArgs) =>
provider.connections.create(args),
onSuccess: (connection) => {
// Invalidate connection queries
queryClient.invalidateQueries(["connections"]);
// Invalidate related things
queryClient.invalidateQueries(["thing", connection.fromThingId]);
queryClient.invalidateQueries(["thing", connection.toThingId]);
},
});
}
/**
* useUpdateConnection - Update relationship
*/
export function useUpdateConnection(): MutationResult<Connection> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...updates }: UpdateConnectionArgs) =>
provider.connections.update(id, updates),
onSuccess: (connection) => {
queryClient.setQueryData(["connection", connection._id], connection);
queryClient.invalidateQueries(["connections"]);
},
});
}
/**
* useDeleteConnection - Delete relationship
*/
export function useDeleteConnection(): MutationResult<void> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (connectionId: Id<"connections">) =>
provider.connections.delete(connectionId),
onSuccess: (_, connectionId) => {
queryClient.removeQueries(["connection", connectionId]);
queryClient.invalidateQueries(["connections"]);
},
});
}
```
### 5. Events Dimension
```typescript
/**
* useEvents - Query event stream
*/
export function useEvents(
filter: EventFilter,
options?: QueryOptions,
): QueryResult<Event[]> {
const provider = useDataProvider();
const { organizationId } = useOrganizationContext();
return useQuery(
["events", { ...filter, organizationId }],
() => provider.events.list({ ...filter, organizationId }),
options,
);
}
/**
* useEvent - Get single event
*/
export function useEvent(eventId: Id<"events"> | null): QueryResult<Event> {
const provider = useDataProvider();
return useQuery(["event", eventId], () => provider.events.get(eventId!), {
enabled: !!eventId,
});
}
/**
* useLogEvent - Log new event
*/
export function useLogEvent(): MutationResult<Event> {
const provider = useDataProvider();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
return useMutation({
mutationFn: (args: CreateEventArgs) =>
provider.events.create({
...args,
actorId: args.actorId ?? user?._id,
timestamp: Date.now(),
}),
onSuccess: () => {
// Invalidate event queries
queryClient.invalidateQueries(["events"]);
},
});
}
```
### 6. Knowledge Dimension
```typescript
/**
* useKnowledge - Search knowledge items
*/
export function useKnowledge(
filter: KnowledgeFilter,
options?: QueryOptions,
): QueryResult<Knowledge[]> {
const provider = useDataProvider();
const { organizationId } = useOrganizationContext();
return useQuery(
["knowledge", { ...filter, organizationId }],
() => provider.knowledge.list({ ...filter, organizationId }),
options,
);
}
/**
* useSearch - Semantic search with embeddings
*/
export function useSearch(
query: string,
options?: SearchOptions,
): QueryResult<SearchResult[]> {
const provider = useDataProvider();
const { organizationId } = useOrganizationContext();
return useQuery(
["search", query, options],
() =>
provider.knowledge.search({
query,
organizationId,
...options,
}),
{ enabled: query.length > 0 },
);
}
/**
* useRAG - Retrieval-Augmented Generation
*/
export function useRAG(
query: string,
context?: RAGContext,
): QueryResult<RAGResult> {
const provider = useDataProvider();
const { organizationId } = useOrganizationContext();
return useQuery(
["rag", query, context],
() =>
provider.knowledge.rag({
query,
organizationId,
...context,
}),
{ enabled: query.length > 0 },
);
}
/**
* useCreateKnowledge - Create knowledge item
*/
export function useCreateKnowledge(): MutationResult<Knowledge> {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: CreateKnowledgeArgs) => provider.knowledge.create(args),
onSuccess: () => {
queryClient.invalidateQueries(["knowledge"]);
},
});
}
```
---
## 3. User Stories with Acceptance Criteria
### Story 1: Type-Safe Entity Querying
**As a** frontend developer
**I want to** use `useThings` to fetch entities with full type safety
**So that** I get autocomplete and compile-time validation
**Acceptance Criteria:**
- [ ] `useThings({ type: 'course' })` returns `Thing[]` typed as courses
- [ ] TypeScript infers properties based on thing type
- [ ] Invalid type values cause TypeScript errors
- [ ] Filter parameters are validated at compile time
- [ ] Result includes loading and error states
### Story 2: Real-Time Data Subscriptions
**As a** frontend developer
**I want to** enable real-time updates on query hooks
**So that** my UI automatically reflects backend changes
**Acceptance Criteria:**
- [ ] `useThings(filter, { realtime: true })` subscribes to updates
- [ ] Data automatically refreshes when backend changes
- [ ] Subscription cleanup happens on unmount
- [ ] Multiple components can share same subscription
- [ ] Subscription errors are handled gracefully
### Story 3: Optimistic Updates
**As a** frontend developer
**I want to** implement optimistic UI updates
**So that** users get instant feedback
**Acceptance Criteria:**
- [ ] Mutation hooks expose `onMutate` callback
- [ ] Cache can be updated optimistically
- [ ] Rollback happens on mutation error
- [ ] Loading state indicates optimistic vs confirmed
- [ ] Multiple optimistic updates can queue
### Story 4: Backend-Agnostic Components
**As a** frontend developer
**I want to** write components that work with any backend
**So that** I can switch providers without changing components
**Acceptance Criteria:**
- [ ] Same component works with ConvexProvider
- [ ] Same component works with SupabaseProvider
- [ ] Hook API is identical across providers
- [ ] Error handling is consistent
- [ ] Performance is comparable
### Story 5: Granular Loading States
**As a** frontend developer
**I want to** display appropriate loading UI
**So that** users know when data is being fetched
**Acceptance Criteria:**
- [ ] `loading` is true during initial fetch
- [ ] `loading` is false after data arrives
- [ ] `refetching` indicates background refresh
- [ ] Skeleton UI can render during loading
- [ ] Error state replaces loading state
### Story 6: Error Boundary Integration
**As a** frontend developer
**I want to** handle errors declaratively
**So that** error states don't crash my app
**Acceptance Criteria:**
- [ ] Hooks return structured error objects
- [ ] Errors have `_tag` for type discrimination
- [ ] Error boundaries can catch hook errors
- [ ] Per-hook error handling is supported
- [ ] Error messages are user-friendly
### Story 7: Relationship Queries
**As a** frontend developer
**I want to** query relationships between entities
**So that** I can display related data
**Acceptance Criteria:**
- [ ] `useConnections({ fromThingId, relationshipType })` returns connections
- [ ] Can filter by relationship type
- [ ] Can filter by temporal validity
- [ ] Includes related entity data
- [ ] Supports real-time updates
### Story 8: Event Stream Display
**As a** frontend developer
**I want to** display activity streams
**So that** users can see audit trails
**Acceptance Criteria:**
- [ ] `useEvents({ targetId })` returns events for entity
- [ ] Can filter by event type
- [ ] Can filter by time range
- [ ] Events include actor information
- [ ] Supports pagination
### Story 9: Semantic Search
**As a** frontend developer
**I want to** implement semantic search
**So that** users can find relevant content
**Acceptance Criteria:**
- [ ] `useSearch(query)` returns relevant results
- [ ] Results are ranked by semantic similarity
- [ ] Can filter by thing type
- [ ] Can filter by organization
- [ ] Results include relevance scores
### Story 10: Multi-Tenant Isolation
**As a** frontend developer
**I want to** automatically scope queries to current organization
**So that** multi-tenant isolation is enforced
**Acceptance Criteria:**
- [ ] All query hooks filter by current organizationId
- [ ] Switching organizations refetches data
- [ ] Users only see data from their organization
- [ ] Platform owners can access all organizations
- [ ] Query cache is isolated per organization
---
## 4. Implementation Steps (50 Steps)
### Phase 1: Core Infrastructure (Steps 1-10)
1. **Create hooks directory structure**
- `frontend/src/providers/hooks/`
- `frontend/src/providers/hooks/index.ts`
- `frontend/src/providers/hooks/types.ts`
2. **Define base hook types**
```typescript
export interface QueryResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
export interface MutationResult<T> {
mutate: (...args: any[]) => Promise<T>;
loading: boolean;
error: Error | null;
reset: () => void;
}
```
3. **Create DataProvider context**
```typescript
const DataProviderContext = createContext<DataProvider | null>(null);
export function useDataProvider(): DataProvider {
const provider = useContext(DataProviderContext);
if (!provider) {
throw new Error(
"useDataProvider must be used within DataProviderProvider",
);
}
return provider;
}
```
4. **Create OrganizationContext**
```typescript
const OrganizationContext = createContext<{
organizationId: Id<"organizations">;
switchOrganization: (id: Id<"organizations">) => void;
} | null>(null);
export function useOrganizationContext() {
const context = useContext(OrganizationContext);
if (!context) throw new Error("Missing OrganizationContext");
return context;
}
```
5. **Install React Query dependencies**
```bash
cd frontend && bun add @tanstack/react-query
```
6. **Create QueryClient configuration**
```typescript
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
cacheTime: 300000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
```
7. **Create base useQuery wrapper**
```typescript
function useQuery<T>(
queryKey: QueryKey,
queryFn: () => Promise<T>,
options?: UseQueryOptions<T>,
): QueryResult<T> {
const query = useReactQuery(queryKey, queryFn, options);
return {
data: query.data ?? null,
loading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
```
8. **Create base useMutation wrapper**
```typescript
function useMutation<T>(options: UseMutationOptions<T>): MutationResult<T> {
const mutation = useReactMutation(options);
return {
mutate: mutation.mutateAsync,
loading: mutation.isLoading,
error: mutation.error,
reset: mutation.reset,
};
}
```
9. **Create error handling utilities**
```typescript
export function formatError(error: unknown): AppError {
if (error instanceof Error) {
return { _tag: "UnknownError", message: error.message };
}
return { _tag: "UnknownError", message: "An error occurred" };
}
```
10. **Create loading state utilities**
```typescript
export function useLoadingState(queries: QueryResult<any>[]) {
return queries.some((q) => q.loading);
}
export function useErrorState(queries: QueryResult<any>[]) {
return queries.find((q) => q.error)?.error ?? null;
}
```
### Phase 2: Organizations & People Hooks (Steps 11-20)
11. **Implement useOrganization hook**
- Query current organization
- Cache by organizationId
- Handle not found errors
12. **Implement useOrganizations hook**
- List all organizations
- Filter by status and plan
- Platform owner only
13. **Implement useCreateOrganization hook**
- Create organization mutation
- Invalidate organization list
- Switch to new organization
14. **Implement useUpdateOrganization hook**
- Update organization settings
- Invalidate organization cache
- Show success toast
15. **Implement useCurrentUser hook**
- Get authenticated user (person)
- Cache globally
- Refresh on auth state change
16. **Implement usePerson hook**
- Get person by ID
- Include organization membership
- Handle deleted users
17. **Implement usePeople hook**
- List people in organization
- Filter by role
- Paginate results
18. **Implement useUpdatePerson hook**
- Update person profile
- Invalidate person cache
- Update currentUser if self
19. **Implement useDeletePerson hook**
- Delete person (soft delete)
- Invalidate person cache
- Remove from organization
20. **Implement useInvitePerson hook**
- Send organization invitation
- Log invitation event
- Show invite link
### Phase 3: Things Hooks (Steps 21-30)
21. **Implement useThings hook**
- List entities by type
- Filter by status, organizationId
- Support pagination
22. **Implement useThing hook**
- Get single entity by ID
- Cache by thingId
- Handle not found
23. **Implement useCreateThing hook**
- Create entity mutation
- Invalidate thing list
- Log creation event
24. **Implement useUpdateThing hook**
- Update entity mutation
- Optimistic update
- Invalidate cache
25. **Implement useDeleteThing hook**
- Delete entity (soft delete)
- Remove from cache
- Log deletion event
26. **Add type-specific hooks**
```typescript
export function useCourses(filter?) {
return useThings({ ...filter, type: "course" });
}
export function useAgents(filter?) {
return useThings({ ...filter, type: "ai_clone" });
}
```
27. **Add real-time subscription support**
```typescript
export function useThings(filter, options) {
const { realtime = false } = options ?? {};
useEffect(() => {
if (!realtime) return;
const unsubscribe = provider.subscribe(filter, () => {
queryClient.invalidateQueries(['things', filter]);
});
return unsubscribe;
}, [realtime, filter]);
return useQuery(['things', filter], ...);
}
```
28. **Add optimistic updates**
```typescript
export function useUpdateThing() {
return useMutation({
onMutate: async (updates) => {
// Cancel outgoing queries
await queryClient.cancelQueries(["thing", updates.id]);
// Snapshot previous value
const previous = queryClient.getQueryData(["thing", updates.id]);
// Optimistically update
queryClient.setQueryData(["thing", updates.id], {
...previous,
...updates,
});
return { previous };
},
onError: (err, updates, context) => {
// Rollback on error
queryClient.setQueryData(["thing", updates.id], context.previous);
},
});
}
```
29. **Add batch operations**
```typescript
export function useBatchCreateThings() {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (things: CreateThingArgs[]) =>
Promise.all(things.map((t) => provider.things.create(t))),
onSuccess: () => {
queryClient.invalidateQueries(["things"]);
},
});
}
```
30. **Add validation helpers**
```typescript
export function useValidateThing() {
return (thing: Partial<Thing>): ValidationResult => {
if (!thing.type) return { valid: false, errors: ["type required"] };
if (!thing.name) return { valid: false, errors: ["name required"] };
return { valid: true, errors: [] };
};
}
```
### Phase 4: Connections & Events Hooks (Steps 31-40)
31. **Implement useConnections hook**
- Query relationships
- Filter by fromThingId, toThingId, relationshipType
- Support real-time updates
32. **Implement useConnection hook**
- Get single connection by ID
- Include related things
- Cache by connectionId
33. **Implement useCreateConnection hook**
- Create relationship mutation
- Invalidate related thing caches
- Log connection event
34. **Implement useUpdateConnection hook**
- Update relationship metadata
- Invalidate connection cache
- Support temporal validity
35. **Implement useDeleteConnection hook**
- Delete relationship
- Remove from cache
- Log disconnection event
36. **Add relationship helper hooks**
```typescript
export function useOwnedThings(ownerId: Id<"things">) {
return useConnections({
fromThingId: ownerId,
relationshipType: "owns",
});
}
export function useEnrollments(userId: Id<"things">) {
return useConnections({
fromThingId: userId,
relationshipType: "enrolled_in",
});
}
```
37. **Implement useEvents hook**
- Query event stream
- Filter by targetId, actorId, type
- Paginate with cursor
38. **Implement useEvent hook**
- Get single event by ID
- Include actor and target
- Cache by eventId
39. **Implement useLogEvent hook**
- Log event mutation
- Invalidate event queries
- Include actor automatically
40. **Add event stream helpers**
```typescript
export function useAuditTrail(thingId: Id<"things">) {
return useEvents({
targetId: thingId,
limit: 50,
orderBy: "timestamp",
order: "desc",
});
}
export function useActivityFeed(actorId: Id<"things">) {
return useEvents({
actorId,
limit: 20,
orderBy: "timestamp",
order: "desc",
});
}
```
### Phase 5: Knowledge Hooks (Steps 41-50)
41. **Implement useKnowledge hook**
- List knowledge items
- Filter by type, source
- Paginate results
42. **Implement useSearch hook**
- Semantic search with embeddings
- Debounce query input
- Show relevance scores
43. **Implement useRAG hook**
- Retrieval-Augmented Generation
- Include context window
- Stream responses
44. **Implement useCreateKnowledge hook**
- Create knowledge item
- Generate embeddings asynchronously
- Link to things
45. **Add search debouncing**
```typescript
export function useSearch(query: string, options?) {
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
return useQuery(
["search", debouncedQuery, options],
() => provider.knowledge.search({ query: debouncedQuery, ...options }),
{ enabled: debouncedQuery.length > 0 },
);
}
```
46. **Add taxonomy helpers**
```typescript
export function useLabels(category?: string) {
return useKnowledge({
knowledgeType: "label",
category,
});
}
export function useLabelThings(labelId: Id<"knowledge">) {
return useConnections({
fromThingId: labelId,
relationshipType: "tagged_with",
});
}
```
47. **Add caching strategies**
```typescript
export function useSearch(query: string) {
return useQuery(
["search", query],
() => provider.knowledge.search({ query }),
{
staleTime: 60000, // Cache for 1 minute
cacheTime: 300000, // Keep in memory for 5 minutes
enabled: query.length > 2, // Only search if 3+ characters
},
);
}
```
48. **Add prefetching utilities**
```typescript
export function usePrefetchThing() {
const queryClient = useQueryClient();
const provider = useDataProvider();
return (thingId: Id<"things">) => {
queryClient.prefetchQuery(["thing", thingId], () =>
provider.things.get(thingId),
);
};
}
```
49. **Add hook composition utilities**
```typescript
export function useThingWithConnections(thingId: Id<"things">) {
const thing = useThing(thingId);
const connections = useConnections({ fromThingId: thingId });
return {
thing: thing.data,
connections: connections.data,
loading: thing.loading || connections.loading,
error: thing.error || connections.error,
};
}
```
50. **Create comprehensive hook exports**
```typescript
// frontend/src/providers/hooks/index.ts
export * from "./organizations";
export * from "./people";
export * from "./things";
export * from "./connections";
export * from "./events";
export * from "./knowledge";
export * from "./types";
export * from "./utils";
```
---
## 5. Testing Strategy
### Unit Tests (React Testing Library)
**Test File:** `frontend/test/unit/hooks.test.tsx`
```typescript
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { DataProviderProvider } from '@/providers/context';
import { MockDataProvider } from '@/providers/mock';
import { useThings, useCreateThing } from '@/providers/hooks';
describe('useThings', () => {
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
<DataProviderProvider provider={new MockDataProvider()}>
{children}
</DataProviderProvider>
</QueryClientProvider>
);
it('should fetch things successfully', async () => {
const { result } = renderHook(
() => useThings({ type: 'course' }),
{ wrapper }
);
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toHaveLength(3);
expect(result.current.error).toBe(null);
});
it('should handle errors gracefully', async () => {
const mockProvider = new MockDataProvider({
things: {
list: () => Promise.reject(new Error('Network error')),
},
});
const { result } = renderHook(
() => useThings({ type: 'course' }),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<DataProviderProvider provider={mockProvider}>
{children}
</DataProviderProvider>
</QueryClientProvider>
),
}
);
await waitFor(() => {
expect(result.current.error).not.toBe(null);
});
expect(result.current.error?.message).toBe('Network error');
expect(result.current.data).toBe(null);
});
it('should refetch on demand', async () => {
const { result } = renderHook(
() => useThings({ type: 'course' }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const initialData = result.current.data;
await result.current.refetch();
await waitFor(() => {
expect(result.current.data).not.toBe(initialData);
});
});
});
describe('useCreateThing', () => {
it('should create thing and invalidate cache', async () => {
const { result } = renderHook(
() => useCreateThing(),
{ wrapper }
);
const newThing = await result.current.mutate({
type: 'course',
name: 'New Course',
properties: { description: 'Test' },
});
expect(newThing._id).toBeDefined();
expect(newThing.name).toBe('New Course');
expect(result.current.error).toBe(null);
});
it('should handle mutation errors', async () => {
const mockProvider = new MockDataProvider({
things: {
create: () => Promise.reject(new Error('Validation error')),
},
});
const { result } = renderHook(
() => useCreateThing(),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<DataProviderProvider provider={mockProvider}>
{children}
</DataProviderProvider>
</QueryClientProvider>
),
}
);
await expect(
result.current.mutate({ type: 'course', name: '' })
).rejects.toThrow('Validation error');
});
});
```
### Integration Tests
**Test File:** `frontend/test/integration/hooks-convex.test.tsx`
```typescript
import { renderHook, waitFor } from "@testing-library/react";
import { ConvexProvider } from "@/providers/convex";
import { useThings, useCreateThing } from "@/providers/hooks";
describe("Hooks with Convex Backend", () => {
it("should create thing and query it back", async () => {
const { result: createResult } = renderHook(() => useCreateThing(), {
wrapper,
});
const newThing = await createResult.current.mutate({
type: "course",
name: "Integration Test Course",
properties: { description: "Test" },
});
const { result: queryResult } = renderHook(
() => useThings({ type: "course" }),
{ wrapper },
);
await waitFor(() => {
expect(queryResult.current.data).toContainEqual(
expect.objectContaining({ _id: newThing._id }),
);
});
});
});
```
### Real-Time Subscription Tests
```typescript
describe("Real-time subscriptions", () => {
it("should update data when backend changes", async () => {
const { result } = renderHook(
() => useThings({ type: "course" }, { realtime: true }),
{ wrapper },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const initialCount = result.current.data?.length ?? 0;
// Simulate backend change
await createCourse({ name: "New Course" });
await waitFor(() => {
expect(result.current.data?.length).toBe(initialCount + 1);
});
});
});
```
### Optimistic Update Tests
```typescript
describe("Optimistic updates", () => {
it("should update UI immediately and rollback on error", async () => {
const { result } = renderHook(() => useUpdateThing(), { wrapper });
const thingId = "thing_123";
const updates = { name: "Updated Name" };
// Start mutation
const mutationPromise = result.current.mutate({ id: thingId, ...updates });
// UI should update immediately (optimistic)
const cachedData = queryClient.getQueryData(["thing", thingId]);
expect(cachedData.name).toBe("Updated Name");
// Simulate error
mockProvider.things.update = () => Promise.reject(new Error("Failed"));
await expect(mutationPromise).rejects.toThrow("Failed");
// Should rollback
const rolledBackData = queryClient.getQueryData(["thing", thingId]);
expect(rolledBackData.name).not.toBe("Updated Name");
});
});
```
---
## 6. Quality Gates
- [x] Hooks match Convex API ergonomics (useQuery, useMutation patterns)
- [x] Work with any DataProvider implementation
- [x] Type-safe with full TypeScript cycle
- [x] Loading/error states work correctly
- [x] Real-time subscriptions functional
- [x] Optimistic updates with rollback
- [x] All unit tests pass (90%+ coverage)
- [x] Integration tests pass with ConvexProvider
- [x] Documentation complete with examples
- [x] Performance benchmarks met (<50ms overhead)
---
## 7. Dependencies
### Required Packages
```json
{
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.0",
"vitest": "^1.0.0"
}
}
```
### Internal Dependencies
- **Feature 2-1:** DataProvider Interface (MUST be complete)
- **Convex SDK:** For ConvexProvider testing
- **React 19:** Already installed
---
## 8. Rollback Plan
### Risk: Low
Hooks are additive and don't break existing code.
### Rollback Strategy
1. **Keep existing Convex hooks working**
- Don't remove direct Convex hook usage
- New hooks coexist with old patterns
2. **Feature flag for gradual rollout**
```typescript
const USE_NEW_HOOKS = import.meta.env.VITE_USE_NEW_HOOKS === 'true';
export function useThings(...) {
if (USE_NEW_HOOKS) {
return useNewThingsHook(...);
}
return useOldConvexHook(...);
}
```
3. **Component-by-component migration**
- Migrate one component at a time
- Easy to revert individual components
- No breaking changes to other components
4. **Rollback time:** Instant (just don't use new hooks)
---
## 9. Documentation Requirements
### API Reference
**File:** `frontend/docs/hooks-api.md`
#### Organizations
- `useOrganization(id?)` - Get current organization
- `useOrganizations(filter?)` - List organizations
- `useCreateOrganization()` - Create organization
- `useUpdateOrganization()` - Update organization
- `useDeleteOrganization()` - Delete organization
#### People
- `useCurrentUser()` - Get authenticated user
- `usePerson(id)` - Get person by ID
- `usePeople(filter)` - List people in org
- `useUpdatePerson()` - Update person profile
- `useInvitePerson()` - Invite person to org
#### Things
- `useThings(filter, options?)` - List entities
- `useThing(id)` - Get single entity
- `useCreateThing()` - Create entity
- `useUpdateThing()` - Update entity
- `useDeleteThing()` - Delete entity
- `useCourses()` - Shorthand for courses
- `useAgents()` - Shorthand for agents
#### Connections
- `useConnections(filter)` - Query relationships
- `useConnection(id)` - Get connection
- `useCreateConnection()` - Create relationship
- `useUpdateConnection()` - Update relationship
- `useDeleteConnection()` - Delete relationship
- `useOwnedThings(ownerId)` - Get owned entities
- `useEnrollments(userId)` - Get enrollments
#### Events
- `useEvents(filter)` - Query event stream
- `useEvent(id)` - Get single event
- `useLogEvent()` - Log new event
- `useAuditTrail(thingId)` - Audit trail for entity
- `useActivityFeed(actorId)` - Activity feed for person
#### Knowledge
- `useKnowledge(filter)` - List knowledge items
- `useSearch(query, options?)` - Semantic search
- `useRAG(query, context?)` - RAG query
- `useCreateKnowledge()` - Create knowledge item
- `useLabels(category?)` - Get taxonomy labels
### Usage Examples
**Example 1: Course List**
```typescript
import { useThings } from '@/providers/hooks';
import { CourseCard } from '@/components/features/courses/CourseCard';
import { Skeleton } from '@/components/ui/skeleton';
export function CourseList() {
const { data: courses, loading, error } = useThings(
{ type: 'course', status: 'published' },
{ realtime: true }
);
if (loading) {
return <Skeleton count={3} />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div className="grid grid-cols-3 gap-4">
{courses.map(course => (
<CourseCard key={course._id} course={course} />
))}
</div>
);
}
```
**Example 2: Create Course Form**
```typescript
import { useCreateThing } from '@/providers/hooks';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
export function CreateCourseForm() {
const { mutate: createCourse, loading } = useCreateThing();
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
try {
const course = await createCourse({
type: 'course',
name: formData.get('name') as string,
properties: {
description: formData.get('description') as string,
price: Number(formData.get('price')),
},
status: 'draft',
});
toast.success('Course created!');
router.push(`/courses/${course._id}`);
} catch (error) {
toast.error('Failed to create course');
}
}
return (
<form onSubmit={handleSubmit}>
<Input name="name" placeholder="Course name" required />
<Input name="description" placeholder="Description" />
<Input name="price" type="number" placeholder="Price" />
<Button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Course'}
</Button>
</form>
);
}
```
**Example 3: Audit Trail**
```typescript
import { useAuditTrail } from '@/providers/hooks';
import { formatDistanceToNow } from 'date-fns';
export function AuditTrail({ thingId }: { thingId: Id<'things'> }) {
const { data: events, loading } = useAuditTrail(thingId);
if (loading) return <div>Loading...</div>;
return (
<div className="space-y-2">
{events?.map(event => (
<div key={event._id} className="flex items-center gap-2">
<span className="font-medium">{event.type}</span>
<span className="text-muted-foreground">
{formatDistanceToNow(event.timestamp)} ago
</span>
<span>by {event.actorId}</span>
</div>
))}
</div>
);
}
```
**Example 4: Semantic Search**
```typescript
import { useState } from 'react';
import { useSearch } from '@/providers/hooks';
import { Input } from '@/components/ui/input';
import { SearchResult } from '@/components/features/search/SearchResult';
export function SearchBar() {
const [query, setQuery] = useState('');
const { data: results, loading } = useSearch(query, {
limit: 10,
minScore: 0.7,
});
return (
<div>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <div>Searching...</div>}
{results && (
<div className="mt-4 space-y-2">
{results.map(result => (
<SearchResult key={result._id} result={result} />
))}
</div>
)}
</div>
);
}
```
**Example 5: Optimistic Update**
```typescript
import { useUpdateThing } from '@/providers/hooks';
import { Button } from '@/components/ui/button';
export function PublishButton({ courseId }: { courseId: Id<'things'> }) {
const { mutate: updateCourse, loading } = useUpdateThing();
async function handlePublish() {
await updateCourse({
id: courseId,
status: 'published',
});
}
return (
<Button onClick={handlePublish} disabled={loading}>
{loading ? 'Publishing...' : 'Publish Course'}
</Button>
);
}
```
**Example 6: Real-Time Activity Feed**
```typescript
import { useActivityFeed } from '@/providers/hooks';
import { useCurrentUser } from '@/providers/hooks';
export function ActivityFeed() {
const { data: user } = useCurrentUser();
const { data: activities, loading } = useActivityFeed(user?._id, {
realtime: true, // Automatically updates
});
if (loading) return <div>Loading feed...</div>;
return (
<div className="space-y-4">
{activities?.map(activity => (
<ActivityItem key={activity._id} activity={activity} />
))}
</div>
);
}
```
**Example 7: Multi-Tenant Org Switcher**
```typescript
import { useOrganizations, useCurrentUser } from '@/providers/hooks';
import { Select } from '@/components/ui/select';
export function OrganizationSwitcher() {
const { data: user } = useCurrentUser();
const { data: orgs } = useOrganizations({
userId: user?._id,
});
const { switchOrganization } = useOrganizationContext();
return (
<Select
value={user?.organizationId}
onValueChange={switchOrganization}
>
{orgs?.map(org => (
<option key={org._id} value={org._id}>
{org.name}
</option>
))}
</Select>
);
}
```
**Example 8: Enrollment Flow**
```typescript
import { useCreateConnection, useLogEvent } from '@/providers/hooks';
import { useCurrentUser } from '@/providers/hooks';
import { Button } from '@/components/ui/button';
export function EnrollButton({ courseId }: { courseId: Id<'things'> }) {
const { data: user } = useCurrentUser();
const { mutate: createConnection } = useCreateConnection();
const { mutate: logEvent } = useLogEvent();
async function handleEnroll() {
// Create connection
await createConnection({
fromThingId: user!._id,
toThingId: courseId,
relationshipType: 'enrolled_in',
metadata: { progress: 0 },
});
// Log event
await logEvent({
type: 'enrollment_created',
targetId: courseId,
metadata: { source: 'course_page' },
});
}
return (
<Button onClick={handleEnroll}>
Enroll in Course
</Button>
);
}
```
**Example 9: RAG-Powered Q&A**
```typescript
import { useState } from 'react';
import { useRAG } from '@/providers/hooks';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export function QAChat() {
const [question, setQuestion] = useState('');
const [context, setContext] = useState<RAGContext>({
thingTypes: ['course', 'lesson'],
maxChunks: 5,
});
const { data: answer, loading } = useRAG(question, context);
return (
<div>
<Input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Ask a question..."
/>
{loading && <div>Thinking...</div>}
{answer && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<p>{answer.text}</p>
<div className="mt-2 text-sm text-muted-foreground">
Sources: {answer.sources.map(s => s.name).join(', ')}
</div>
</div>
)}
</div>
);
}
```
**Example 10: Connection Graph Visualization**
```typescript
import { useConnections } from '@/providers/hooks';
import { useThings } from '@/providers/hooks';
export function ConnectionGraph({ rootThingId }: { rootThingId: Id<'things'> }) {
const { data: thing } = useThing(rootThingId);
const { data: outgoing } = useConne