On this pageParent-Owned Changes
Informing Submodels
A Submodel sometimes needs to react to a change it does not own. The URL may change, a server may push new data, or a sibling field may establish a new constraint. The parent observes that change, but the child still owns the state transition it causes.
Export an inform* helper from the child. The helper runs an internal child Message through update and returns the next child Model and Commands. The parent folds that helper with Update.foldChild, so it never imports or constructs the internal Message.
Use inform* when the child must derive a transition or return Commands. Use a silent reflect* helper when the child only needs to conform one of its values to an external source.
The example below uses routing. A People Submodel owns its search input, results, and recent searches. The root owns the Route. When the URL resolves to a People Route, the root calls People.informRouteChanged.
Prerequisite
This page builds on the Submodels pattern. Read that first if the Got*Message wrapping convention is unfamiliar.
People declares an internal ChangedRoute Message carrying only PeopleRoute, the part of the application Route that the feature understands.
Update copies the route query into the input, records it in recent searches, and returns FetchPeople for the new results.
informRouteChanged is the public entry point. It calls update(model, ChangedRoute({ route })), keeping the Message constructor private.
import { Option, Schema, String } from 'effect'
import { type Update } from 'foldkit'
import { defineMessageUnion } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { PeopleRoute } from '../route'
// MESSAGE
const Person = Schema.Struct({
id: Schema.Number,
name: Schema.String,
role: Schema.String,
})
export const Message = defineMessageUnion({
ChangedSearchInput: { value: Schema.String },
SubmittedSearch: {},
ChangedRoute: { route: PeopleRoute },
SucceededFetchPeople: {
query: Schema.String,
people: Schema.Array(Person),
},
})
export type Message = typeof Message.Type
// UPDATE
export const update = (model: Model, message: Message) =>
Message.match<Update.Return<Model, Message>>(message, {
ChangedSearchInput: ({ value }) => ({
model: evo(model, { searchInput: () => value }),
}),
SubmittedSearch: () => ({
model,
commands: [
PushSearchUrl({
searchText: Option.liftPredicate(
model.searchInput,
String.isNonEmpty,
),
}),
],
}),
ChangedRoute: ({ route }) => {
const searchText = Option.getOrElse(route.searchText, () => '')
return {
model: evo(model, {
searchInput: () => searchText,
searchHistory: searchHistory =>
addSearchToHistory(searchHistory, searchText),
results: () => SearchLoading(),
}),
commands: [FetchPeople({ searchText })],
}
},
SucceededFetchPeople: ({ query, people }) => ({
model: evo(model, { results: () => SearchLoaded({ query, people }) }),
}),
})
export const informRouteChanged = (model: Model, route: PeopleRoute) =>
update(model, Message.ChangedRoute({ route }))Not an OutMessage
ChangedRoute moves from parent to child through an inform* helper. An OutMessage moves a fact from child to parent.
The root defines one fold for regular People Messages and another for informRouteChanged. Both folds use the same read, write, and toParentMessage boundary. The ChangedUrl handler stores the next Route, then composes the relevant child step with Update.combine.
import { Match, Option } from 'effect'
import { Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { People } from './page'
const foldPeople = Update.foldChild({
update: People.update,
read: (model: Model) => Option.some(model.peoplePage),
write: (model, nextPeoplePage) =>
evo(model, { peoplePage: () => nextPeoplePage }),
toParentMessage: message => Message.GotPeopleMessage({ message }),
})
const foldPeopleRouteChanged = Update.foldChild({
update: People.informRouteChanged,
read: (model: Model) => Option.some(model.peoplePage),
write: (model, nextPeoplePage) =>
evo(model, { peoplePage: () => nextPeoplePage }),
toParentMessage: message => Message.GotPeopleMessage({ message }),
})
const setRoute =
(nextRoute: AppRoute): Update.Step<Model, Message> =>
model => ({ model: evo(model, { route: () => nextRoute }) })
export const update = (model: Model, message: Message) =>
Message.match<UpdateReturn>(message, {
ChangedUrl: ({ url }) => {
const nextRoute = urlToAppRoute(url)
const routeSteps = Match.value(nextRoute).pipe(
Match.withReturnType<ReadonlyArray<Update.Step<Model, Message>>>(),
Match.tag('People', peopleRoute => [
foldPeopleRouteChanged(peopleRoute),
]),
Match.orElse(() => []),
)
return Update.combine(model, [setRoute(nextRoute), ...routeSteps])
},
GotPeopleMessage: ({ message }) => foldPeople(model, message),
})Multiple Submodels
When several page Submodels react to routing, match the next Route and return the informRouteChanged step for the page that owns that Route.
Cold loads
informRouteChanged handles later URL changes. On a cold load, root init parses the initial URL and passes the People Route to child init. See Cold Loads and the Initial Route.
The Routing example contains the complete search flow. Routing and Navigation covers the parser that produces these Routes.
The same pattern works when a Subscription or Command tells the parent about a change the child must process. The cause changes, but ownership does not.