I'll preface this by saying I have a LOT of freedom in the app that I'm currently working on, and I've taken that liberty to push the envelope of curiosities. I recently read about signals (specifically @preact/signal-react) and it got me to thinking. This is the brainchild first iteration of this. I am posting this with hopes that one of two things happen. I hope first and foremost that I can learn from someone in some way here. If I'm short-sighted or missing something, point it out, please. I've been coding for years but still feel brand new and still make a lot of mistakes. If it helps someone else learn something, well that's ok too.
Conceptually, I build my domain objects using a building block class that I call a "ReactiveMap". Feel free to swap this out for any type of implementation you want to use for your domain model's base, but this works for my particular use case.
The ReactiveMap mimics the api of a javascript Map object with some utility additions, and adds the ability for you to opt in for observation on a couple of different facets. My use cases are mostly object based, and some are collections of those objects.
Because my base abstraction works around a Map, I use a Map. I wrote some utility functions that get an array representation of the keys of the map (which I call the "collection" ) and I also convert the entire map into a POJO with the "data" property. These are kept up to date using computed signals, which are observable themselves - this is important later when it comes to tying it in to the useSyncExternalStore hook.
For the sake of not leaving anything to imagination, I'm just going to paste in my entire ReactiveMap file. Feel free to judge, it won't hurt my feelings... much.
import { computed, signal } from '@preact/signals-react'
export class ReactiveMap {
constructor (initialValue = {}) {
this.signal = signal(new Map())
this.defaultValue = signal(ensureIsMap(initialValue))
this.filterCriteria = signal(null)
this.filterBy = 'name'
this.filterFn = signal(null)
this.collection = computed(() => {
const baseCollection = Array.from(this.signal.value.values())
// allows for customization past any decent static configuration. Pass a function and we will pass each entry.
if (this.filterFn.value !== null) { return baseCollection.filter(this.filterFn.value) }
// if the filterFn isn't in place to override, and the criteria value is null, return the entire collection
if (this.filterCriteria.value === null) { return baseCollection }
// if the criteria is set to a value, run collection through filter fn. Supports Strings, Maps and Objects.
const filterExp = new RegExp(RegExp.escape(this.filterCriteria.value), 'i')
return baseCollection.filter(entry => {
const filterBy = typeof entry.get === 'function' ? entry.get(this.filterBy) : entry[this.filterBy]
return filterExp.test(filterBy)
})
})
this.data = computed(() => Object.fromEntries(this.signal.value))
// Updates data signal when default is changed [where defaultValue is only set on `setTo` call or init]
this.defaultValue.subscribe(defaultMap => { this.signal.value = new Map(defaultMap) })
}
get (key) { return this.signal.value.get(key) }
has (key) { return this.signal.value.has(key) }
set (key, value) {
const newMap = new Map(this.signal.value)
newMap.set(key, value)
this.signal.value = newMap
return this
}
unset (key) {
const newMap = new Map(this.signal.value)
newMap.delete(key)
this.signal.value = newMap
return this
}
find (conditionFn) { return this.collection.value.find(conditionFn) }
subscribe (cb) { return this.signal.subscribe(cb) }
subscribeToCollection (cb) { return this.collection.subscribe(cb) }
reset () { this.signal.value = new Map(this.defaultValue.value) }
setTo (data) { this.defaultValue.value = ensureIsMap(data) }
$push (key, propertyToAdd) {
const entry = this.signal.value.get(key)
entry.push(propertyToAdd)
return this.set(key, entry)
}
$pull (key, propertyToRemove) {
const entry = this.signal.value.get(key)
return this.set(key, entry.filter(value => value !== propertyToRemove))
}
}Now that I have the ReactiveMap established, I start building DO's from it. My base DO class essentially enables each DO instance derived from the base class to have access to the api service via the ServiceConnector (as well as an API request crafted to take signed s3 urls from my service and upload directly from the client). I've left the basics of the CRUD operations in as well, but there are additional wrappers for error handling and other customizations depending on domain and implementation within the components that consume them.
Note: this application leans VERY heavily to optimistic display. I create the DO's client side and push them to the server side for eventual persistence. There are mitigations in place across the app to ensure the user sees what actually exists as well, but I won't go too far into that. Tanstack query enables something like this also I believe?
class ConnectedDomainObject extends ReactiveMap {
constructor ({ type, data }) {
super(data)
this.type = type
this.ops = ops[type]
this.service = new ServiceConnector()
this.s3 = new S3Connector()
}
// As "commit" is semantically correct for the type of operation we are doing, its commonly
// also referred to as creating the record. Most records will, however, be created client side and will after-the-fact
// need to be persisted to the database - thus, committing it to persistence.
async commit () {
const setRecordResult = await this.service.transmit({ operation: this.ops.commit, data: this.data.value })
this.setTo(setRecordResult.data)
}
async refresh () {
const retrieveResult = await this.service.transmit({ operation: this.ops.retrieve, id: this.getData.id })
this.setTo(retrieveResult.data)
}
async remove () { return this.service.transmit({ operation: this.ops.remove, id: this.data.value.id }) }
async update (property, value, operation = 'set') {
this[operation](property, value)
const updateResult = await this.service.transmit({ operation: this.ops.update, payload: { id: this.data.value.id, ...this.data.value } })
this.setTo(updateResult.data)
}
}With the base connected DO class, we can then start building each domain out how we want it. No boilerplate redux stores to fit square pegs into round holes, just implement the things we need per domain from here. This is an example of a single domain object that I call a "Project". I'll also provide the class that manages the collection of Projects.
SiteGroup and FilesHierarchy are also domains of their own. Since we are composing our app of these interconnected objects, when one is a parent of another, I just instantiate where it makes sense. It's super easy to track the mental model this way.
Use your imagination on the imports. I thought about painting the whole picture, but don't want to rabbit trail as much as possible.
export class Project extends ConnectedDomainObject {
constructor ({
id = getObjectId(),
name,
division,
thumbnail,
collaborators = [],
fundingSources = [],
sites = []
}) {
super({ type: 'projects' })
this.sites = new SiteGroup()
this.files = new FilesHierarchy(id)
this.siteCount = computed(() => this.sites.isLoaded ? this.sites.collection.value.length : sites.length)
this.setTo({ id, name, division, thumbnail, collaborators, fundingSources })
}
getThumbnail () {
const s3Key = this.data.value?.thumbnail?.startsWith('thumbnails')
? this.data.value.thumbnail
: `projects/${this.data.value.id}/${this.data.value.thumbnail}`
return getS3FormattedUrl(s3Key)
}
async confirmRemove () {
return confirmation.getResult({ prompt: `Delete Project: "${this.data.value.name}"?`, destructive: true })
}
}SiteGroup implements an ObjectGroup class - which is a ReactiveMap implementation in and of itself. ObjectGroups know the concept of loading data, as well as managing inclusion of individual objects within their group.
export class ObjectGroup extends ReactiveMap {
constructor ({ activeId, ObjectType, ops, initialValue = {} } = {}) {
super(initialValue)
this.activeId = activeId
this.ObjectType = ObjectType
this.ops = ops
this.service = new ServiceConnector()
this.activeObject = activeId
? computed(() => this.data.value[Navigation.activeParams.data.value[this.activeId]])
: signal(null)
}
subscribeToActiveObject (cb) { return this.activeObject.subscribe(cb) }
setActive (setToActive) { this.activeObject.value = setToActive }
get isLoading () { return this.service.resolver !== null && this.service.resolver.status === 'pending' }
get isLoaded () { return this.service.resolver !== null && this.service.resolver.status === 'fulfilled' }
async load (operationParams = {}) {
if (!this.isLoaded && !this.isLoading) {
const { data } = await this.service.transmit({ operation: this.ops.retrieve, ...operationParams })
const newMap = new Map()
data.forEach(doc => {
const instance = new this.ObjectType(doc)
newMap.set(instance.data.value.id, instance)
})
this.setTo(newMap)
}
return this.service.resolver
}
add (obj) { return this.set(obj.data.value.id, obj) }
async remove (objId) {
if (this.has(objId)) {
if (await this.get(objId).confirmRemove()) {
// remove data from server side store
await this.get(objId).remove()
// remove from collection after server side removal call
this.unset(objId)
}
} else {
console.warn('attempted to remove objectId that doesnt exist?')
}
}
}Now - with all of that said. If you are still reading, this is how it integrates with React. Up until now - all of the above code is just plain JS. That's intentional! The less react-specific stuff we have, the easier it is to test, maintain, and frankly borrow from for reuse elsewhere in other concepts.
In the index.js file where we store the DO objects for state, I create the hooks that are imported into the components that consume them. Here is an example.
// build domain object
export const Projects = new ProjectGroup()
/*
export hook that listens to changes in the collection and updates so a component that is rendering the list of projects will
be able to react to it.
*/
export const useProjects = () => useSyncExternalStore((cb) => Projects.subscribeToCollection(cb), () => Projects.collection.value)The beautiful thing about computed signals also is that they only re-compute when data changes from what they are computed from. This essentially gives us built in memoization, which is required by the useSyncExternalStore hook.
And finally, it's used in the presentation logic like so:
- in my router declaration, projectLoader function is set as the client side loader for the picker root.
- using RR7's Await component and wrapped in Suspense, we get all the beauty of that stuff. The service.resolver.value maintains the promise that represents the fetch call, so as data needs to be loaded, it calls for it and waits (showing the fallback) or returns the captured resolved value of the promise. Since the Domain Object itself manages data post-initial load, this is all the UI needs to be concerned about.
export function projectLoader () {
if (!Projects.isLoaded.value) {
Projects.load()
}
return { resolver: Projects.service.resolver.value }
}
export function ProjectPickerRoot () {
const { resolver } = useLoaderData() // uses React Router 7 framework mode client side loader
return (
<RootWrapper>
<Suspense fallback={<ProjectLoadingStencils />}>
<Await resolve={resolver} errorElement={<EHP />}>
<ProjectTiles />
</Await>
</Suspense>
</RootWrapper>
)
}
export function ProjectTiles () {
const projectList = useProjects()
if (projectList.length === 0) { return <ProjectsEmptyState />}
return (
<ProjectTilesWrapper numProjects={projectList.length}>
{projectList.map(projectObj => <ProjectTile key={projectObj.data.id} obj={projectObj} />)}
</ProjectTilesWrapper>
)
}I know this was robust and I probably over-explained or over-shared. Often times I find more context is better than less. Now, feel free to tear this apart and I'd love to learn better ways to do things within this space. It's been an interesting way to tie it all together though.
Thanks very much for this explanation. I've been thinking along similar lines for a new project but it hadn't quite gelled that I could use signals and the
useSyncExternalStorehook for this. You've given me a lot to think about.