15 KiB
NDK Comprehensive Guide
Last Updated: 2025-03-26
Status: Active
Related To: Nostr Integration, Social Features, State Management
Purpose
This document provides a comprehensive guide to using the Nostr Development Kit (NDK) in the POWR app. It serves as the primary reference for implementing Nostr features, covering core concepts, state management patterns, subscription lifecycles, and mobile-specific considerations. This guide is specifically tailored for the MVP implementation, focusing on simplified social features while establishing a solid foundation for future enhancements.
UPDATED FOR MVP: This guide has been consolidated and aligned with our simplified social approach for the MVP release.
This guide combines key information from our various NDK analysis documents and the ndk-mobile package to provide a comprehensive reference for implementing Nostr features in POWR app. It's organized to support our MVP strategy with a focus on the core NDK capabilities we need.
Table of Contents
- NDK Core Concepts
- Proper State Management
- Subscription Lifecycle
- NIP-19 and Encoding/Decoding
- Context Provider Pattern
- Mobile-Specific Considerations
- Best Practices for POWR MVP
NDK Core Concepts
NDK (Nostr Development Kit) provides a set of tools for working with the Nostr protocol. Key components include:
- NDK Instance: Central object for interacting with the Nostr network
- NDKEvent: Represents a Nostr event
- NDKUser: Represents a user (pubkey)
- NDKFilter: Defines criteria for querying events
- NDKSubscription: Manages subscriptions to event streams
- NDKRelay: Represents a connection to a relay
Basic Setup
import NDK from '@nostr-dev-kit/ndk';
// Initialize NDK with relays
const ndk = new NDK({
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band'
]
});
// Connect to relays
await ndk.connect();
Proper State Management
The ndk-mobile library emphasizes proper state management using Zustand. This approach is critical for avoiding the issues we've been seeing in our application.
Zustand Store Pattern
// Create a singleton store using Zustand
import { create } from 'zustand';
// Private store instance - not exported
let store: ReturnType<typeof createNDKStore> | undefined;
// Store creator function
const createNDKStore = () => create<NDKState>((set) => ({
ndk: undefined,
initialized: false,
connecting: false,
connectionError: null,
initialize: (config) => set((state) => {
if (state.initialized) return state;
const ndk = new NDK(config);
return { ndk, initialized: true };
}),
connect: async () => {
set({ connecting: true, connectionError: null });
try {
const ndk = store?.getState().ndk;
if (!ndk) throw new Error("NDK not initialized");
await ndk.connect();
set({ connecting: false });
} catch (error) {
set({ connecting: false, connectionError: error });
}
}
}));
// Getter for the singleton store
export const getNDKStore = () => {
if (!store) {
store = createNDKStore();
}
return store;
};
// Hook for components to use
export const useNDKStore = <T>(selector: (state: NDKState) => T) => {
// Ensure the store exists
getNDKStore();
// Return the selected state
return useStore(store)(selector);
};
Critical State Management Points
- Single Store Instance: Always use a singleton pattern for NDK stores
- Lazy Initialization: Only create the store when first accessed
- Proper Selectors: Select only what you need from the store
- Clear State Transitions: Define explicit state transitions (connecting, connected, error)
Subscription Lifecycle
Proper subscription management is crucial for app stability and performance. The subscribe.ts file in ndk-mobile provides advanced subscription handling.
Basic Subscription Pattern
import { useEffect } from 'react';
import { useNDK } from './hooks/useNDK';
function EventComponent({ filter }) {
const { ndk } = useNDK();
const [events, setEvents] = useState([]);
useEffect(() => {
if (!ndk) return;
// Create subscription
const sub = ndk.subscribe(filter, {
closeOnEose: false, // Keep connection open
});
// Handle incoming events
sub.on('event', (event) => {
setEvents(prev => [...prev, event]);
});
// Start subscription
sub.start();
// Critical: Clean up subscription when component unmounts
return () => {
sub.stop();
};
}, [ndk, JSON.stringify(filter)]);
return (/* render events */);
}
Enhanced Subscription Hook
The ndk-mobile package includes an enhanced useSubscribe hook with additional features:
// Example based on ndk-mobile implementation
function useEnhancedSubscribe(filter, options = {}) {
const { ndk } = useNDK();
const [events, setEvents] = useState([]);
const [eose, setEose] = useState(false);
const subRef = useRef(null);
useEffect(() => {
if (!ndk) return;
// Create subscription
const sub = ndk.subscribe(filter, {
closeOnEose: options.closeOnEose || false,
wrap: options.wrap || false
});
subRef.current = sub;
// Handle incoming events
sub.on('event', (event) => {
// Process events (filtering, wrapping, etc.)
// Add to state
setEvents(prev => {
// Check for duplicates using event.id
if (prev.some(e => e.id === event.id)) return prev;
return [...prev, event];
});
});
// Handle end of stored events
sub.on('eose', () => {
setEose(true);
});
// Start subscription
sub.start();
// Clean up
return () => {
if (subRef.current) {
subRef.current.stop();
}
};
}, [ndk, JSON.stringify(filter), options]);
return { events, eose };
}
NIP-19 and Encoding/Decoding
NIP-19 functions are essential for handling Nostr identifiers like npub, note, and naddr.
Decoding NIP-19 Entities
import { nip19 } from '@nostr-dev-kit/ndk';
// Decode any NIP-19 entity (naddr, npub, nsec, note, etc.)
function decodeNIP19(encoded: string) {
try {
const decoded = nip19.decode(encoded);
// decoded.type will be 'npub', 'note', 'naddr', etc.
// decoded.data will contain the data specific to that type
return decoded;
} catch (error) {
console.error('Invalid NIP-19 format:', error);
return null;
}
}
// Convert npub to hex pubkey
function npubToHex(npub: string) {
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
return decoded.data as string; // This is the hex pubkey
}
return null;
} catch (error) {
console.error('Invalid npub format:', error);
return null;
}
}
Encoding to NIP-19 Formats
// Create an npub from a hex public key
function hexToNpub(hexPubkey: string) {
return nip19.npubEncode(hexPubkey);
}
// Create a note (event reference) from event ID
function eventIdToNote(eventId: string) {
return nip19.noteEncode(eventId);
}
// Create an naddr for addressable events
function createNaddr(pubkey: string, kind: number, identifier: string) {
return nip19.naddrEncode({
pubkey, // Hex pubkey
kind, // Event kind (number)
identifier // The 'd' tag value
});
}
Context Provider Pattern
The ndk-mobile package emphasizes the Context Provider pattern for proper NDK integration:
import { NDKProvider } from '@nostr-dev-kit/ndk-mobile';
import App from './App';
// Root component
export default function Root() {
return (
<NDKProvider
params={{
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band'
]
}}
>
<App />
</NDKProvider>
);
}
This pattern ensures:
- Single NDK Instance: The entire app shares one NDK instance
- Consistent State: Auth state and relay connections are managed in one place
- Hooks Availability: All NDK hooks (useNDK, useSubscribe, etc.) work correctly
- Proper Cleanup: Connections and subscriptions are managed appropriately
Mobile-Specific Considerations
The ndk-mobile package includes several mobile-specific optimizations:
Caching Strategy
Mobile devices need efficient caching to reduce network usage and improve performance:
import { SQLiteAdapter } from '@nostr-dev-kit/ndk-mobile/cache-adapter/sqlite';
// Initialize NDK with SQLite caching
const ndk = new NDK({
explicitRelayUrls: [...],
cacheAdapter: new SQLiteAdapter({
dbName: 'nostr-cache.db'
})
});
Mobile Signers
For secure key management on mobile devices:
import { SecureStorageSigner } from '@nostr-dev-kit/ndk-mobile/signers/securestorage';
// Use secure storage for private keys
const signer = new SecureStorageSigner({
storageKey: 'nostr-private-key'
});
// Add to NDK
ndk.signer = signer;
Best Practices for POWR MVP
Based on our analysis and the ndk-mobile implementation, here are key best practices for our MVP:
1. State Management
- Use singleton stores for NDK and session state
- Implement proper state machine for auth transitions with Zustand
- Add event listeners/callbacks for components to respond to auth changes
// Authentication state using Zustand (aligned with ndk-mobile patterns)
export const useAuthStore = create((set) => ({
state: 'unauthenticated', // 'unauthenticated', 'authenticating', 'authenticated', 'deauthenticating'
user: null,
error: null,
startAuthentication: async (npub) => {
set({ state: 'authenticating', error: null });
try {
// Authentication logic here
const user = await authenticateUser(npub);
set({ state: 'authenticated', user });
} catch (error) {
set({ state: 'unauthenticated', error });
}
},
logout: async () => {
set({ state: 'deauthenticating' });
// Cleanup logic
set({ state: 'unauthenticated', user: null });
},
}));
2. Subscription Management
- Always clean up subscriptions when components unmount
- Keep subscription references in useRef to ensure proper cleanup
- Use appropriate cache strategies to reduce relay load
- Implement proper error handling for subscription failures
// Example component with proper subscription management
function WorkoutShareComponent({ workoutId }) {
const { ndk } = useNDK();
const [relatedPosts, setRelatedPosts] = useState([]);
const subRef = useRef(null);
useEffect(() => {
if (!ndk) return;
// Create subscription for posts mentioning this workout
const sub = ndk.subscribe({
kinds: [1],
'#e': [workoutId]
}, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
});
subRef.current = sub;
// Handle events
sub.on('event', (event) => {
setRelatedPosts(prev => [...prev, event]);
});
// Start subscription
sub.start();
// Clean up on unmount
return () => {
if (subRef.current) {
subRef.current.stop();
}
};
}, [ndk, workoutId]);
// Component JSX
}
3. Simplified Social Features
For the MVP, we're focusing on:
- Workout sharing: Publishing kind 1301 workout records and kind 1 notes quoting them
- Official feed: Display only POWR official account posts in the social tab
- Static content: Prefer static content over complex real-time feeds
// Example function to share a workout
async function shareWorkout(ndk, workout, caption) {
// First, publish the workout record (kind 1301)
const workoutEvent = new NDKEvent(ndk);
workoutEvent.kind = 1301;
workoutEvent.content = JSON.stringify(workout);
// Add appropriate tags
workoutEvent.tags = [
['d', workout.id],
['title', workout.title],
['duration', workout.duration.toString()]
];
// Publish workout record
const workoutPub = await workoutEvent.publish();
// Then create a kind 1 note quoting the workout
const noteEvent = new NDKEvent(ndk);
noteEvent.kind = 1;
noteEvent.content = caption || `Just completed ${workout.title}!`;
// Add e tag to reference the workout event
noteEvent.tags = [
['e', workoutEvent.id, '', 'quote']
];
// Publish social note
await noteEvent.publish();
return { workoutEvent, noteEvent };
}
4. Error Handling & Offline Support
- Implement graceful fallbacks for network errors
- Store pending publish operations for later retry
- Use SQLite caching for offline access to previously loaded data
// Publication queue service example (aligned with mobile patterns)
class PublicationQueue {
async queueForPublication(event) {
try {
// Try immediate publication
await event.publish();
return true;
} catch (error) {
// Store for later retry
await this.storeEventForLater(event);
return false;
}
}
async publishPendingEvents() {
const pendingEvents = await this.getPendingEvents();
for (const event of pendingEvents) {
try {
await event.publish();
await this.markAsPublished(event);
} catch (error) {
// Keep in queue for next retry
console.error("Failed to publish:", error);
}
}
}
}
5. Provider Pattern
- Use NDKProvider to wrap the application
- Configure NDK once at the app root level
- Access NDK via hooks rather than creating instances
// App.tsx
export default function App() {
return (
<NDKProvider
params={{
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band'
]
}}
>
<YourAppComponents />
</NDKProvider>
);
}
// Using NDK in components
function SomeComponent() {
const { ndk } = useNDK();
// Use ndk here
return (/* component JSX */);
}
6. MVP Implementation Focus
For the MVP, focus on implementing:
- Core Authentication: Proper login/logout with state management
- Workout Sharing: Publication of workouts to Nostr
- Limited Social Features: Static feed of official POWR account
- User Profile: Basic user information display
Defer these for post-MVP:
- Full social feed implementation
- Real-time following/interaction
- Complex subscription patterns
By following these best practices, we can create a stable foundation for the POWR app's MVP release that addresses the current architectural issues while providing a simplified but functional social experience.
Related Documentation
- Nostr Exercise NIP - Technical specification for Nostr workout events
- Social Architecture - Social integration architecture
- Publication Queue Service - Offline-first sync queue implementation
- Project MVP and Targeted Rebuild - Overall project roadmap and priorities
- NDK Initialization - Current implementation of NDK initialization
- NDK Subscription Analysis - In-depth analysis of NDK subscription patterns