Skip to main content
Skip to main content

React Integration

This guide covers integrating HexDI with React applications using @hex-di/react.

Installation

pnpm add hex-di @hex-di/react

Overview

@hex-di/react provides:

  • ContainerProvider - Makes container available to components
  • ScopeProvider - Manual scope management
  • AutoScopeProvider - Automatic scope lifecycle
  • usePort - Resolve services in components
  • useContainer - Access the container directly
  • useScope - Access the current scope

Basic Setup

1. Create Typed Hooks

First, create typed hooks for your application's ports:

// src/di/hooks.ts
import { createTypedHooks } from "@hex-di/react";
import type { AppPorts } from "./ports";

// Create hooks typed to your ports
const typedHooks = createTypedHooks<AppPorts>();

export const ContainerProvider = typedHooks.ContainerProvider;
export const ScopeProvider = typedHooks.ScopeProvider;
export const AutoScopeProvider = typedHooks.AutoScopeProvider;
export const usePort = typedHooks.usePort;
export const useContainer = typedHooks.useContainer;
export const useScope = typedHooks.useScope;

2. Create Your Graph and Container

// src/di/container.ts
import { createContainer } from "@hex-di/runtime";
import { appGraph } from "./graph";

export const container = createContainer({ graph: appGraph, name: "App" });

3. Wrap Your App with ContainerProvider

// src/App.tsx
import { ContainerProvider } from './di/hooks';
import { container } from './di/container';

export function App() {
return (
<ContainerProvider container={container}>
<MyApp />
</ContainerProvider>
);
}

4. Use Services in Components

// src/components/Dashboard.tsx
import { usePort } from '../di/hooks';
import { LoggerPort, UserServicePort } from '../di/ports';

export function Dashboard() {
const logger = usePort(LoggerPort);
const userService = usePort(UserServicePort);

useEffect(() => {
logger.log('Dashboard mounted');
}, [logger]);

return <div>Dashboard</div>;
}

Provider Components

ContainerProvider

The root provider that makes the container available to the component tree.

import { ContainerProvider } from './di/hooks';
import { container } from './di/container';

function App() {
return (
<ContainerProvider container={container}>
<MyApp />
</ContainerProvider>
);
}

Props:

  • container (required) - The container instance created from your graph

ScopeProvider

Provides a manually-managed scope to children.

import { ScopeProvider, useContainer } from './di/hooks';

function RequestHandler() {
const container = useContainer();
const scope = useMemo(() => container.createScope(), [container]);

useEffect(() => {
return () => {
void scope.tryDispose();
};
}, [scope]);

return (
<ScopeProvider scope={scope}>
<RequestContent />
</ScopeProvider>
);
}

Props:

  • scope (required) - A scope instance created from the container

AutoScopeProvider

Automatically creates a scope on mount and disposes it on unmount.

import { AutoScopeProvider } from './di/hooks';

function UserDashboard() {
return (
<AutoScopeProvider>
{/* Children have access to scoped services */}
<UserProfile />
<UserSettings />
</AutoScopeProvider>
);
}

Props:

  • children - React children

Behavior:

  • Creates a new scope when the component mounts
  • Disposes the scope when the component unmounts
  • Children can resolve scoped services

Hooks

usePort

Resolves a service from the container or current scope.

function MyComponent() {
const logger = usePort(LoggerPort);

// logger is typed as Logger
logger.log("Hello!");
}

For scoped services, must be inside a ScopeProvider or AutoScopeProvider:

function UserProfile() {
// This requires being inside a scope provider
const session = usePort(UserSessionPort);

return <div>User: {session.user.name}</div>;
}

useContainer

Access the container directly for advanced use cases.

function NotificationButton() {
const container = useContainer();

const handleClick = () => {
// Resolve a transient service
container.tryResolve(NotificationPort).match(
(notification) => { notification.send('Button clicked!'); },
(error) => { console.error('Failed to resolve notification:', error); },
);
};

return <button onClick={handleClick}>Notify</button>;
}

useScope

Access the current scope (only available inside a scope provider).

function ScopedComponent() {
const scope = useScope();

useEffect(() => {
console.log("Inside scope:", scope);
}, [scope]);
}

Scope Management Patterns

Pattern 1: User Session Scope

Create a new scope when the user changes:

function App() {
const [currentUser, setCurrentUser] = useState<User | null>(null);

// Key forces re-mount when user changes
const scopeKey = currentUser?.id ?? 'anonymous';

return (
<ContainerProvider container={container}>
<AutoScopeProvider key={scopeKey}>
<UserContext.Provider value={currentUser}>
<MainApp />
</UserContext.Provider>
</AutoScopeProvider>
</ContainerProvider>
);
}

Pattern 2: Route-Based Scopes

Create a scope per route:

function AppRoutes() {
const location = useLocation();

return (
<AutoScopeProvider key={location.pathname}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</AutoScopeProvider>
);
}

Pattern 3: Modal Scopes

Isolate modal state with scopes:

function UserModal({ userId, onClose }) {
return (
<AutoScopeProvider>
<Modal onClose={onClose}>
<UserEditor userId={userId} />
</Modal>
</AutoScopeProvider>
);
}

Working with Singleton Services

Singleton services work the same everywhere:

function LogViewer() {
const logger = usePort(LoggerPort); // Singleton

// This is the same logger instance everywhere
// No scope required
}

function AnotherComponent() {
const logger = usePort(LoggerPort); // Same instance
}

Working with Scoped Services

Scoped services require a scope context:

function UserProfile() {
// This will throw MissingProviderError if not in a scope
const session = usePort(UserSessionPort);

return <div>Welcome, {session.user.name}!</div>;
}

// Usage - must wrap in scope provider
function App() {
return (
<ContainerProvider container={container}>
<AutoScopeProvider>
<UserProfile /> {/* Works! */}
</AutoScopeProvider>
</ContainerProvider>
);
}

Working with Transient Services

Transient services create fresh instances each resolution:

function NotificationDemo() {
const container = useContainer();
const [instances, setInstances] = useState<number[]>([]);

const handleClick = () => {
// Each call creates a new instance
container.tryResolve(NotificationPort).match(
(notif) => { setInstances(prev => [...prev, notif.instanceId]); },
(error) => { console.error('Failed to resolve notification:', error); },
);
};

return (
<div>
<button onClick={handleClick}>Create Notification</button>
<ul>
{instances.map(id => (
<li key={id}>Instance #{id}</li>
))}
</ul>
</div>
);
}

Reactive Updates

Subscribing to Singleton State

For services with subscriptions:

function MessageList() {
const messageStore = usePort(MessageStorePort);
const [messages, setMessages] = useState(() => messageStore.getMessages());

useEffect(() => {
// Subscribe to updates
const unsubscribe = messageStore.subscribe(setMessages);
return unsubscribe;
}, [messageStore]);

return (
<ul>
{messages.map(msg => (
<li key={msg.id}>{msg.content}</li>
))}
</ul>
);
}

Using with External State

Combine HexDI with other state management:

function App() {
const [user, setUser] = useState(null);
const container = useMemo(() => createContainer({ graph, name: "App" }), []);

// Update adapter factory state before scope creation
const handleLogin = (userData) => {
setCurrentUserData(userData); // Module-level state
setUser(userData);
};

return (
<ContainerProvider container={container}>
<AutoScopeProvider key={user?.id ?? 'anon'}>
<AuthContext.Provider value={{ user, setUser: handleLogin }}>
<App />
</AuthContext.Provider>
</AutoScopeProvider>
</ContainerProvider>
);
}

Error Handling

MissingProviderError

Thrown when hooks are used outside their required context:

function BadComponent() {
// Throws MissingProviderError - no ContainerProvider ancestor
const logger = usePort(LoggerPort);
}

// Fix: Wrap in ContainerProvider
function App() {
return (
<ContainerProvider container={container}>
<BadComponent /> {/* Now works */}
</ContainerProvider>
);
}

Error Boundaries

Use error boundaries to catch resolution errors:

class DIErrorBoundary extends Component {
state = { hasError: false, error: null };

static getDerivedStateFromError(error) {
return { hasError: true, error };
}

render() {
if (this.state.hasError) {
if (this.state.error instanceof MissingProviderError) {
return <div>Missing DI provider: {this.state.error.message}</div>;
}
throw this.state.error;
}
return this.props.children;
}
}

SSR Considerations

No Global State

createTypedHooks() creates isolated instances, safe for SSR:

// Each createTypedHooks call creates isolated context
// No global state pollution between requests
const hooks = createTypedHooks<AppPorts>();

Per-Request Containers

Create a new container per request:

import { fromPromise } from '@hex-di/result';

// Next.js example
export async function getServerSideProps(context) {
const container = createContainer({ graph, name: "SSR" });
const result = await container.tryResolve(DataServicePort)
.asyncAndThen((dataService) => fromPromise(dataService.fetchData(), (e) => e));
await container.tryDispose();
return result.match(
(data) => ({ props: { data } }),
(error) => ({ notFound: true }),
);
}

Hydration

Pass initial data to avoid refetching:

function App({ initialData }) {
const container = useMemo(() => {
const c = createContainer({ graph, name: "App" });
// Hydrate with server data
c.tryResolve(DataStorePort).match(
(store) => { store.hydrate(initialData); },
(error) => { console.error('Failed to hydrate data store:', error); },
);
return c;
}, []);

return (
<ContainerProvider container={container}>
<MyApp />
</ContainerProvider>
);
}

Complete Example

// di/ports.ts
import { port } from '@hex-di/core';

export const LoggerPort = port<Logger>()({ name: 'Logger' });
export const UserSessionPort = port<UserSession>()({ name: 'UserSession' });
export const ChatServicePort = port<ChatService>()({ name: 'ChatService' });

export type AppPorts =
| typeof LoggerPort
| typeof UserSessionPort
| typeof ChatServicePort;

// di/hooks.ts
import { createTypedHooks } from '@hex-di/react';
import type { AppPorts } from './ports';

const hooks = createTypedHooks<AppPorts>();
export const { ContainerProvider, AutoScopeProvider, usePort } = hooks;

// di/container.ts
import { createContainer } from '@hex-di/runtime';
import { appGraph } from './graph';
export const container = createContainer({ graph: appGraph, name: "App" });

// App.tsx
import { ContainerProvider, AutoScopeProvider } from './di/hooks';
import { container } from './di/container';

export function App() {
const [currentUser, setCurrentUser] = useState('alice');

return (
<ContainerProvider container={container}>
<AutoScopeProvider key={currentUser}>
<ChatRoom />
<UserSwitcher onSwitch={setCurrentUser} />
</AutoScopeProvider>
</ContainerProvider>
);
}

// components/ChatRoom.tsx
import { usePort } from '../di/hooks';
import { ChatServicePort, UserSessionPort } from '../di/ports';

export function ChatRoom() {
const chat = usePort(ChatServicePort);
const session = usePort(UserSessionPort);

const handleSend = (message: string) => {
chat.sendMessage(message);
};

return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<MessageInput onSend={handleSend} />
</div>
);
}

Next Steps