Skip to main content

⚙️ Client Lifecycle

Understanding XHub Chat client lifecycle - from initialization to shutdown.

Overview

The XHub Chat client goes through several phases during its lifetime:

┌─────────────┐
│ Created │ ← new XHubChatClient(config)
└──────┬──────┘

│ startClient()

┌─────────────┐
│ Starting │ ← Loading cache, connecting
└──────┬──────┘

│ Sync begins

┌─────────────┐
│ Prepared │ ← Ready to use
└──────┬──────┘

│ Syncing

┌─────────────┐
│ Syncing │ ← Receiving updates
└──────┬──────┘

│ stopClient()

┌─────────────┐
│ Stopped │ ← Sync stopped, data saved
└──────┬──────┘

│ destroy()

┌─────────────┐
│ Destroyed │ ← Resources released
└─────────────┘

Lifecycle Phases

1. Creation

What happens:

  • Client instance is created
  • Configuration is validated
  • Store is initialized (but not started)
  • HTTP API is configured
  • Event emitter is set up

Code:

import { createClient } from '@xhub-chat/core';

const client = createClient({
baseUrl: 'https://your-server.com',
accessToken: 'your-token',
userId: '@user:example.com',
store: {
type: 'indexeddb',
dbName: 'xhub-chat',
},
});

// Client state: Created
// No network activity yet
// No data loaded

State:

  • client.clientRunning: false
  • client.syncState: SyncState.Stopped
  • Store: Not connected
  • Events: Can be registered

2. Starting

What happens:

  • Store connects to database
  • Cached data is loaded (if available)
  • Sync preparation begins
  • Initial sync request is sent

Code:

await client.startClient({
initialSyncLimit: 20, // Load last 20 messages per room
});

// Client state: Starting → Prepared

Internal Flow:

export class XHubChatClient {
public async startClient(opts?: IStartClientOpts): Promise<void> {
try {
// 1. Mark as running
this.clientRunning = true;
this.emit(ClientEvent.Sync, SyncState.Preparing);

// 2. Start store (connect to IndexedDB)
await this.store.startup();

// 3. Load cached rooms
const cachedRooms = await this.store.getSavedRooms();
if (cachedRooms) {
for (const roomData of cachedRooms) {
const room = this.createRoom(roomData.roomId);
room.deserialize(roomData);
this.store.storeRoom(room);
}
}

// 4. Start sync
await this.syncApi.sync(opts);

// 5. Mark as prepared
this.emit(ClientEvent.Sync, SyncState.Prepared);

} catch (error) {
this.emit(ClientEvent.Sync, SyncState.Error, error);
throw error;
}
}
}

Events Emitted:

// Listen for lifecycle events
client.on(ClientEvent.Sync, (state, prevState) => {
console.log(`Sync state: ${prevState}${state}`);
});

// Events during startup:
// 1. "sync" with SyncState.Preparing
// 2. "sync" with SyncState.Prepared

3. Prepared

What happens:

  • Initial sync completed
  • Cached data loaded
  • Client ready for use
  • Sync loop not yet started

When to use:

  • Perfect time to initialize UI
  • Rooms are available
  • Can send messages
  • Timeline may be partial

Code:

client.once(ClientEvent.Sync, (state) => {
if (state === SyncState.Prepared) {
console.log('Client is ready!');

// Safe to access rooms
const rooms = client.getRooms();
console.log(`Found ${rooms.length} rooms`);

// Can send messages
client.sendTextMessage(roomId, 'Hello!');
}
});

4. Syncing

What happens:

  • Continuous sync loop running
  • Receiving new events in real-time
  • Timeline updates
  • Presence updates
  • Typing indicators

Code:

client.on(ClientEvent.Sync, (state) => {
if (state === SyncState.Syncing) {
console.log('Receiving updates...');
}
});

// Listen for new messages
client.on(ClientEvent.RoomTimeline, (event, room) => {
if (event.getType() === 'm.room.message') {
console.log(`New message in ${room?.name}: ${event.getContent().body}`);
}
});

Sync Loop:

export class SyncApi {
public async sync(opts?: IStartClientOpts): Promise<void> {
while (this.shouldSync()) {
try {
// 1. Send sync request
const response = await this.client.http.authedRequest(
Method.Get,
'/sync',
{
timeout: 30000,
since: this.client.store.getSyncToken(),
}
);

// 2. Emit syncing state
this.client.emit(ClientEvent.Sync, SyncState.Syncing);

// 3. Process response
await this.processSyncResponse(response);

// 4. Update token
this.client.store.setSyncToken(response.next_batch);

// 5. Save to database
await this.client.store.save();

// 6. Continue loop

} catch (error) {
await this.handleSyncError(error);
}
}
}
}

5. Stopping

What happens:

  • Sync loop is terminated
  • Pending changes are saved
  • Network requests are cancelled
  • No new events processed

Code:

// Stop the client
await client.stopClient();

// Client state: Stopped
// Data is preserved
// Can restart with startClient()

Internal Flow:

export class XHubChatClient {
public async stopClient(): Promise<void> {
// 1. Stop sync loop
this.syncApi.stop();
this.clientRunning = false;

// 2. Save pending data
await this.store.save(true); // force save

// 3. Emit stopped state
this.emit(ClientEvent.Sync, SyncState.Stopped);
}
}

6. Destroyed

What happens:

  • All resources released
  • Database connections closed
  • Event listeners removed
  • Cannot be restarted

Code:

// Destroy the client (permanent)
await client.destroy();

// Client state: Destroyed
// Cannot be reused
// Create new instance if needed

Internal Flow:

export class XHubChatClient {
public async destroy(): Promise<void> {
// 1. Stop client if running
if (this.clientRunning) {
await this.stopClient();
}

// 2. Destroy store
await this.store.destroy();

// 3. Remove all listeners
this.removeAllListeners();

// 4. Clear references
this.store = null!;
this.syncApi = null!;
this.http = null!;
}
}

React Integration

Provider Lifecycle

import React, { useEffect, useRef } from 'react';
import { createClient, XHubChatClient } from '@xhub-chat/core';

export function XHubChatProvider({ config, children }) {
const clientRef = useRef<XHubChatClient | null>(null);
const [isReady, setIsReady] = useState(false);

useEffect(() => {
// 1. Create client
const client = createClient(config);
clientRef.current = client;

// 2. Listen for ready state
const onSync = (state: SyncState) => {
if (state === SyncState.Prepared) {
setIsReady(true);
}
};
client.on(ClientEvent.Sync, onSync);

// 3. Start client
client.startClient().catch(console.error);

// 4. Cleanup on unmount
return () => {
client.off(ClientEvent.Sync, onSync);
client.stopClient().then(() => {
client.destroy();
});
};
}, [config]);

if (!isReady) {
return <div>Loading...</div>;
}

return (
<XHubChatContext.Provider value={clientRef.current}>
{children}
</XHubChatContext.Provider>
);
}

Hook Lifecycle

export function useRoom(roomId: string) {
const client = useXHubChat();
const [room, setRoom] = useState<Room | null>(null);

useEffect(() => {
// Get initial room
const initialRoom = client.getRoom(roomId);
setRoom(initialRoom);

// Listen for updates
const onRoom = (updatedRoom: Room) => {
if (updatedRoom.roomId === roomId) {
setRoom(updatedRoom);
}
};

client.on(ClientEvent.Room, onRoom);

return () => {
client.off(ClientEvent.Room, onRoom);
};
}, [client, roomId]);

return room;
}

Error Handling

Sync Errors

client.on(ClientEvent.Sync, (state, prevState, data) => {
if (state === SyncState.Error) {
const error = data as Error;

if (error.name === 'ConnectionError') {
// Network issue - will retry automatically
console.log('Connection lost, retrying...');
} else if (error.name === 'M_UNKNOWN_TOKEN') {
// Token invalid - need to re-login
handleLogout();
} else {
// Other error
console.error('Sync error:', error);
}
}
});

Store Errors

try {
await client.startClient();
} catch (error) {
if (error.name === 'InvalidStateError') {
// IndexedDB not available
console.log('Falling back to memory storage');

// Recreate with memory store
const newClient = createClient({
...config,
store: { type: 'memory' },
});

await newClient.startClient();
}
}

Best Practices

1. Always Clean Up

// ✅ Good
useEffect(() => {
client.startClient();

return () => {
client.stopClient();
};
}, [client]);

// ❌ Bad - memory leak
useEffect(() => {
client.startClient();
// No cleanup!
}, [client]);

2. Wait for Prepared State

// ✅ Good
const [isReady, setIsReady] = useState(false);

useEffect(() => {
const onSync = (state: SyncState) => {
if (state === SyncState.Prepared) {
setIsReady(true);
}
};

client.on(ClientEvent.Sync, onSync);
client.startClient();

return () => client.off(ClientEvent.Sync, onSync);
}, []);

if (!isReady) return <Loading />;

// ❌ Bad - accessing data too early
useEffect(() => {
client.startClient();
const rooms = client.getRooms(); // May be empty!
}, []);

3. Handle Errors Gracefully

// ✅ Good
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
const onSync = (state: SyncState, _, data: any) => {
if (state === SyncState.Error) {
setError(data);
}
};

client.on(ClientEvent.Sync, onSync);

return () => client.off(ClientEvent.Sync, onSync);
}, []);

if (error) {
return <ErrorView error={error} onRetry={() => client.startClient()} />;
}

4. Use Single Client Instance

// ✅ Good - single instance
const client = useMemo(() => createClient(config), []);

// ❌ Bad - creates new instance every render
const client = createClient(config);

Debugging

Log Lifecycle Events

const states = [
SyncState.Preparing,
SyncState.Prepared,
SyncState.Syncing,
SyncState.Stopped,
SyncState.Error,
];

client.on(ClientEvent.Sync, (state, prevState) => {
console.log(`[Lifecycle] ${prevState}${state}`);
console.log(`[Lifecycle] Timestamp: ${new Date().toISOString()}`);
console.log(`[Lifecycle] Rooms: ${client.getRooms().length}`);
});

Monitor Performance

const startTime = Date.now();

client.once(ClientEvent.Sync, (state) => {
if (state === SyncState.Prepared) {
const duration = Date.now() - startTime;
console.log(`[Performance] Client ready in ${duration}ms`);
}
});

await client.startClient();

State Diagram

                      ┌─────────────┐
│ Created │
└──────┬──────┘

startClient()


┌─────────────┐
│ Preparing │
└──────┬──────┘

(Cache loaded)


┌─────────────┐
│ Prepared │◄──┐
└──────┬──────┘ │
│ │
(Start sync loop) │
│ │
▼ │
┌─────────────┐ │
│ Syncing │───┘
└──────┬──────┘

stopClient()


┌─────────────┐
│ Stopped │
└──────┬──────┘

destroy()


┌─────────────┐
│ Destroyed │
└─────────────┘

Next Steps