3. Building Reactive UI with Models
Models are pre-built subscriptions that combine the EventStore with helpers to provide computed state. Instead of manually subscribing to events and parsing them, models give you clean, reactive data that automatically updates when relevant events change.
What are Models?
Models are functions that:
- Combine EventStore subscriptions with helpers - No manual parsing needed
- Provide computed state - Transform raw events into useful data structures
- Cache results - Same model with same parameters reuses the same observable
- Handle complex logic - Like loading missing data or combining multiple event types
- Return RxJS observables - Can be subscribed to or used with operators
Using Models with EventStore
The eventStore.model()
method is how you create and subscribe to models:
import { ProfileModel } from "applesauce-core/models";
// Create a model subscription
const profileSubscription = eventStore
.model(ProfileModel, "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")
.subscribe((profile) => {
if (profile) {
console.log("Profile:", profile);
// Profile is already parsed and ready to use
console.log("Name:", profile.name);
console.log("About:", profile.about);
} else {
console.log("Profile not found");
}
});
// Don't forget to unsubscribe when done
// profileSubscription.unsubscribe();
ProfileModel Example
The ProfileModel
automatically handles profile events (kind 0) and parses their content:
import { EventStore } from "applesauce-core";
import { ProfileModel } from "applesauce-core/models";
const eventStore = new EventStore();
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
// Subscribe to profile updates
eventStore.model(ProfileModel, pubkey).subscribe((profile) => {
if (profile) {
console.log("User profile updated:");
console.log("- Name:", profile.name || "No name");
console.log("- About:", profile.about || "No bio");
console.log("- Picture:", profile.picture || "No picture");
console.log("- Website:", profile.website || "No website");
}
});
// Add a profile event to see the model in action
eventStore.add({
content:
'{"name":"fiatjaf","about":"~","picture":"https://fiatjaf.com/static/favicon.jpg","nip05":"_@fiatjaf.com","lud16":"fiatjaf@zbd.gg","website":"https://nostr.technology"}',
created_at: 1738588530,
id: "c43be8b4634298e97dde3020a5e6aeec37d7f5a4b0259705f496e81a550c8f8b",
kind: 0,
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
relays: [""],
sig: "202a1bf6a58943d660c1891662dbdda142aa8e5bca9d4a3cb03cde816ad3bdda6f4ec3b880671506c2820285b32218a0afdec2d172de9694d83972190ab4f9da",
tags: [],
});
// The subscription will fire with the parsed profile data
TimelineModel Example
The TimelineModel
provides a sorted, reactive timeline of events:
import { TimelineModel } from "applesauce-core/models";
// Subscribe to all text notes (kind 1)
eventStore.model(TimelineModel, { kinds: [1] }).subscribe((timeline) => {
console.log(`Timeline updated: ${timeline.length} notes`);
timeline.forEach((note, index) => {
console.log(`${index + 1}. ${note.content.slice(0, 50)}...`);
console.log(` By: ${note.pubkey.slice(0, 8)}`);
console.log(` At: ${new Date(note.created_at * 1000).toLocaleString()}`);
});
});
// Add some notes to see the timeline update
eventStore.add({
content: 'I just wish LLMs would stop saying their solutions are "comprehensive" or "powerful"',
created_at: 1749596768,
id: "77941979d4c04283fd9b2f0a280749248cbd41babe3a0731c1597a6d54ae7874",
kind: 1,
pubkey: "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322",
sig: "a0884549f09ef805d3ffa917c3d9e189882295f1b819c038e5d28ea1a668f4455f66ada40749dbdb6dfd48c323f507889330a2a4742b0cb66d8997afb31ff47e",
tags: [["client", "Coracle", "31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690"]],
});
eventStore.add({
content: "These numbers are so kind.",
created_at: 1745847253,
id: "621233a1ad1b91620f0b4a308c2113243a98925909cdb7b26284cbb4d835a18c",
kind: 1,
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
sig: "eb8f30f7c44d031bfe315d476165f5cf29c21f1eaf07128f5a673cdb3b69ebf7902dacc06987f8d764b17225aefecdbc91992165e03372e40f57639a41203a1c",
tags: [],
});
// Timeline will update twice, showing 1 note then 2 notes
Model Caching
Models with the same parameters are cached and reused:
// These two subscriptions share the same underlying model
const subscription1 = eventStore
.model(ProfileModel, "621233a1ad1b91620f0b4a308c2113243a98925909cdb7b26284cbb4d835a18c")
.subscribe(console.log);
const subscription2 = eventStore
.model(ProfileModel, "621233a1ad1b91620f0b4a308c2113243a98925909cdb7b26284cbb4d835a18c")
.subscribe(console.log);
// Both subscriptions will receive the same data
// Only one actual subscription is created internally
// Always unsubscribe when done
subscription1.unsubscribe();
subscription2.unsubscribe();
Combining Models with RxJS Operators
Since models return RxJS observables, you can use operators to transform the data:
import { map, filter } from "rxjs/operators";
// Only get profiles that have names
eventStore
.model(ProfileModel, pubkey)
.pipe(
filter((profile) => profile && profile.name),
map((profile) => profile.name.toUpperCase()),
)
.subscribe((name) => {
console.log("Profile name in caps:", name);
});
// Get timeline length
eventStore
.model(TimelineModel, { kinds: [1] })
.pipe(map((timeline) => timeline.length))
.subscribe((count) => {
console.log("Timeline has", count, "notes");
});
Other Useful Models
MailboxesModel
Gets a user's inbox and outbox relays from their relay list (kind 10002):
import { MailboxesModel } from "applesauce-core/models";
eventStore.model(MailboxesModel, pubkey).subscribe((mailboxes) => {
if (mailboxes) {
console.log("Outbox relays:", mailboxes.outboxes);
console.log("Inbox relays:", mailboxes.inboxes);
}
});
RepliesModel
Gets all replies to a specific event:
import { RepliesModel } from "applesauce-core/models";
const noteId = "abc123...";
eventStore.model(RepliesModel, noteId).subscribe((replies) => {
console.log(`Found ${replies.length} replies to note ${noteId}`);
replies.forEach((reply) => {
console.log(`- ${reply.content.slice(0, 50)}...`);
});
});
ContactsModel
Gets a user's contact list (kind 3):
import { ContactsModel } from "applesauce-core/models";
eventStore.model(ContactsModel, pubkey).subscribe((contacts) => {
if (contacts) {
console.log("Following", contacts.length, "people:");
contacts.forEach((contact) => {
console.log(`- ${contact.pubkey.slice(0, 8)}`);
});
}
});
Working with Multiple Models
You can combine multiple models using the combineLatest
operator from RxJS to build complex views:
import { combineLatest } from "rxjs";
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
// Combine profile and timeline data
combineLatest([
eventStore.model(ProfileModel, pubkey),
eventStore.model(TimelineModel, { kinds: [1], authors: [pubkey] }),
]).subscribe(([profile, timeline]) => {
const name = profile?.name || "Unknown";
const noteCount = timeline.length;
console.log(`${name} has posted ${noteCount} notes`);
});
Missing Event Handling
Models handle missing events gracefully and typically return undefined
when events aren't available:
eventStore.model(ProfileModel, "nonexistent-pubkey").subscribe((profile) => {
if (profile === undefined) {
console.log("Profile not found or still loading");
} else {
console.log("Profile loaded:", profile);
}
});
Model Performance
Models are designed for performance:
- Cached - Models with the same parameters are reused
- Lazy - Only created when subscribed to
- Efficient - Use internal EventStore subscriptions
- Memory-safe - Automatically cleaned up when no subscribers
// This is efficient - only one ProfileModel created
const model = eventStore.model(ProfileModel, pubkey);
const sub1 = model.subscribe(handleProfile1);
const sub2 = model.subscribe(handleProfile2);
const sub3 = model.subscribe(handleProfile3);
// Clean up all subscriptions
sub1.unsubscribe();
sub2.unsubscribe();
sub3.unsubscribe();
Key Concepts
- Models transform raw events into useful data structures
- Use
eventStore.model()
to create model subscriptions - Models return observables - can be used with RxJS operators
- Models handle parsing - no need to use helper methods manually
- Models are reactive - automatically update when events change
Example: React user profile
For React applications, you can use the useObservableMemo
hook from applesauce-react
to easily integrate models:
import React from "react";
import { useObservableMemo } from "applesauce-react/hooks";
import { ProfileModel, TimelineModel } from "applesauce-core/models";
import { getDisplayName, getProfilePicture } from "applesauce-core/helpers";
function UserProfile({ pubkey }: { pubkey: string }) {
// Create a new model for the user's profile and subscribe to it
const profile = useObservableMemo(() => eventStore.model(ProfileModel, pubkey), [pubkey]);
// Create a new model for the user's notes and subscribe to it
const timeline = useObservableMemo(
() => eventStore.model(TimelineModel, { kinds: [1], authors: [pubkey] }),
[pubkey],
);
const displayName = getDisplayName(profile, pubkey.slice(0, 8) + "...");
const avatar = getProfilePicture(profile, `https://robohash.org/${pubkey}.png`);
return (
<div className="user-profile">
<header>
<img src={avatar} alt={displayName} />
<h1>{displayName}</h1>
{profile?.about && <p>{profile.about}</p>}
</header>
<main>
<h2>Notes ({timeline?.length || 0})</h2>
{timeline?.map((note) => (
<article key={note.id}>
<p>{note.content}</p>
<time>{new Date(note.created_at * 1000).toLocaleString()}</time>
</article>
))}
</main>
</div>
);
}