The WarmAndFuzzy web app
« Details: API implementation | The WarmAndFuzzy mobile app » |
Show me what you got
Status
The main page gives a quick run-down of what everything is up to (yeah, it's basically a database dump):
Settings
The other page any actual human might want to interact with is the thermostat settings page:
Each thermostat first lists its holds followed by its schedule steps (sorted by time-of-day).
The approach to editing a setting is heavily inspired by Bret Victor's manifesto Magic Ink: Information Software and the Graphical Interface from 2006. I've always wanted to build UI this way but repeatedly was dismissed for wanting to build some sort of "MadLibs" craziness. Well, for better or worse, there's nobody here to tell me what to do, so here we are.
Every element of a setting (e.g. Weekdays at 07:00, heat to 24°C) is clickable and uses a pop-up for editing. The only thing keeping this from being fully word-based is that I use color-coded iconography instead of "heat to" etc. since I think it's easier to parse visually and it takes up less space (and it still works for color-blind people).
The system does let you select arbitrary days (e.g. just Mondays and Wednesdays), but it's also smart enough to collapse the obvious combinations of days into "weekdays", "weekends", and "everyday".
Once a setting is changed, an undo and save button are injected into the setting "bean" for the modified setting:
Preferences
There's not a lot that folks can adjust about how they experience the system individually; I have begrudgingly added the ability to experience the system through irrational units (Fahrenheit) rather than the sane and reasonable system default of Celsius.
Configuration
All of the stuff that gets touched during system setup and then never again lives under the System Configuration
tab and it looks basically like the database UI that it is, plus proper units annotations.
This was the first editing UI I created so I'm just phoning it in with a Modal:
How it's built
The web app is built using React and uses semantic-ui-react
as its foundational UX element library. I was tired of everything in the world looking like Bootstrap but hadn't quite realized that semantic-ui-react
seems to be close to abandoned. It does work fine, though certain things like its handling of themes and colors is kind of half baked (e.g. you cannot set custom colors for elements). ++
okay but would not buy again.
Data and state
The single greatest move forward in the app(s) was to move my data and state into a MobX store, and to generalize GraphQL access via MobX-based helper classes.
Here's how this breaks down:
AuthStore
is a centralized store for maintaining authentication state and tokens.- Side note: While
AuthStore
doesn't do the authentication itself, it exposes a reference to anAuthProvider
, an interface implemented by the web and mobile app code (different implementations because Auth0 needs different kinds of babysitting in R vs. RN).AuthStore
just acts as a link toAuthProvider
so that application code has a simple way of (e.g.) requesting logins, etc. without me having to stand up yet another React context provider. Anyhow.
- Side note: While
StoreBase
provides the basic structure that any store should follow:- An
@observable
state
enum ("fetching" | "updating" | "ready" | "error"
). @computed
semantic accessors for that state so I can expand the state definitions in the future, e.g.isWorking
currently coversfetching
andupdating
, but could also cover, say,deleting
, if that became a thing in the future.error
andlastUpdated
properties for the obvious.- A
name
for debugging purposes.
- An
GraphqlStoreBase
is a templatized (generic) implementation ofStoreBase
for read-only GraphQL-sourced stores. It is configured with the type of the stored item and the query.- Constructing a MobX-compliant
async
function is pretty extra (private readonly fetchData = flow(function*(this: GraphqlStoreBase<T, TQuery>) {...}
) so this was nice to have to do just once. - The store sets up a MobX
autorun
in the constructor referencing the auth state from theAuthStore
. If a user logs in, the store automatically fetches data; if a user logs out, it automatically clears itself. This chaining of observable state and actions across MobX stores is just hella slick. GraphqlStoreBase
requires that stored items have anid
string property and offers afindItemById
function. The function just performs a linear search since our data is so small that I cannot imagine that this is worth doing any better.latestThermostatValues
is a good example of a consumer (or rather, actual implementor) ofGraphqlStoreBase
, including its use ofQueryResultDataItemPatcher<T>
to fix up return items (rehydratingDate
types).
- Constructing a MobX-compliant
GraphqlMutableStoreBase
derives fromGraphqlStoreBase
and provides anupdateItem(id)
function, again leveraging the fact that every item has anid
.ThermostatSettingsStore
is a good example of a consumer (or rather, again, actual implementor) ofGraphqlMutableStoreBase
.
StoreBase
, to go back to the beginning for a minute, also makes it easy to build a helper likeStoreChecks
, a component for the web and mobile apps to guard pages/components/etc. that rely on stores to be loaded. It just accepts anything that's aStoreBase
.- The web and mobile implementations are slightly different owing to minor differences between React and ReactNative. Sigh.
- The
RootStore
owns all of the stores. useRootStore()
is made available as a React context provider to all components.- The order of instantiation at the top level of each app is straight-forward:
- Instantiate an
AuthProvider
. - Instantiate an
AuthStore
based on thatAuthProvider
. - Inform the
AuthProvider
whichAuthStore
it should push updates into- The cross-linking of these two is a bit arbitrary but it allows the
AuthStore
to have areadonly
reference to theAuthProvider
, thus this order.
- The cross-linking of these two is a bit arbitrary but it allows the
- Instantiate an
ApolloClient
which will auto-inject the access token from theAuthStore
into each outgoing request and use another slick-ass MobXreaction
(since we didn't need a full-onautorun
) to clear the Apollo cache whenever a user logs out. - Instantiate the
RootStore
. - Wrap the app in a
RootStoreContext.Provider
.
- Instantiate an
This system is really lovely for this application. Structurally, it has a few downsides:
- Every store always loads everything.
- Every store always loads all fields (or at least all the fields I ever need across the apps), somewhat missing out on one of the premises of GraphQL to let sites of use specify their local minimum set of fields.
However, there just isn't a lot of data in our system to begin with. Order-of-magnitude of ten thermostats, clients on WiFi, on-the-wire compression - it's just not worth doing better. Each app (mobile or web) has more or less the same capabilities so they might as well share exactly the same stores.
Side note: The web app is the only place that accesses timeseries data (thermostat and sensor streams); that access is managed through dedicated stores since they're filtered by the UI and it is high-volume data.
The Graphql[Mutable]StoreBase
infrastructure is designed for stores of many/multiple items so when it came to build the UserPreferencesStore
I had to improvise a bit, since a user only gets a single set of strongly typed preferences. I used the fixup capabilities of GraphqlStoreBase
and GraphqlMutableStoreBase
to respectively inject and remove an id
into the type and data of the strongly typed preferences item, and offered it up for consumers through a @computed
userPreferences
property. It's slightly weird but a detailed comment documents it well enough and saves me from hundreds of lines of code duplication.
Modifying data in the UI
My preferred pattern has become to use useState
to set up mutable copy of the store item being modified, using that for isDirty
detection as well, and then writing back into the store whenever the Save button is pushed.
A simple application of this is for the UserPreferences
page:
const UserPreferences: React.FunctionComponent = (): React.ReactElement => {
const rootStore = useRootStore();
const authStore = rootStore.authStore;
const userPreferencesStore = rootStore.userPreferencesStore;
const userFirstName = authStore.userName?.split(" ")[0];
const userPreferences = userPreferencesStore.userPreferences;
const [mutableUserPreferences, setMutableUserPreferences] = useState(userPreferences);
const [isSaving, setIsSaving] = useState(false);
const isUserPreferencesDirty = !UserPreferencesSchema.UserPreferencesIsEqual(
userPreferences,
mutableUserPreferences
);
return (
<StoreChecks requiredStores={[userPreferencesStore]}>
<Container style={{ paddingTop: "2rem" }} text>
<Header as="h4" attached="top" block>
Let's get this right for you, {userFirstName}.
</Header>
<Segment attached>
<Form loading={isSaving}>
<Form.Group>
<Form.Select
fluid
label="Preferred temperature units"
onChange={(
_event: React.SyntheticEvent<HTMLElement>,
data: DropdownProps
): void => {
setMutableUserPreferences({
...mutableUserPreferences,
temperatureUnits: data.value as GraphQL.TemperatureUnits,
});
}}
options={[
GraphQL.TemperatureUnits.Celsius,
GraphQL.TemperatureUnits.Fahrenheit,
].map(
(temperatureUnit): DropdownItemProps => {
return { key: temperatureUnit, value: temperatureUnit, text: temperatureUnit };
}
)}
value={mutableUserPreferences.temperatureUnits}
/>
</Form.Group>
<Form.Button
content={isSaving ? "Saving..." : "Save"}
disabled={!isUserPreferencesDirty}
icon="save"
onClick={async (): Promise<void> => {
setIsSaving(true);
await userPreferencesStore.updateUserPreferences(mutableUserPreferences);
setIsSaving(false);
}}
positive
/>
</Form>
</Segment>
</Container>
</StoreChecks>
);
};
export default observer(UserPreferences);
Other tips and tricks
React Router is pretty dope for managing getting around the app. AuthenticatedRoute
is a neat little trick for bouncing unauthenticated callers back to the main page.
If you need a cleaner example of interacting with Auth0 than any sample I've been able to find on the internet (if I may say so), look at Auth
.
Lessons learned
Assume that most architecture shown in Getting started!-type guides on the internet is not very good.
The React-ish/Functional-ish programming paradigm seems to still be foreign to many engineers, which is entirely fair considering that we're all taught imperative languages from the on-set so the one-directional state flow / single assignment philosophy feels really different.
(I was fortunate to have been taught how to write fairly functional-ish C++ (const
all the things!) by a very smart co-worker early on, so this is somewhat more natural to me.)
Once you've wrapped your newly melted brain around React's approach to state (which they of course keep changing refine their guidance on every six months, bless their hearts) you have to contend with the reality that dogmatic top-down data flow also doesn't work super-well past toy scale.
You've got a logout button at the bottom of the UI hierarchy that somehow needs to find the auth component to request a logout, then the auth component somehow needs to find the GraphQL component to tell it to clear its cache, and then the GraphQL component (or whatever) needs to find other local stores to erase all their contents, and then the UI needs to reflorgle itself in the absence of all that data.
It's just a bloody nightmare, and it's no wonder that basically no Getting started! guide gets it right. They generally get a single part of the system right (logging in or out, or querying data, or storing data) but tying it all is invariably in danger of becoming spaghetti code.
MobX makes this make sense by allowing for loosely coupled state dependencies that still let each part of the system be as Functional-ish as it wants to be.
To be clear, WarmAndFuzzy didn't burst onto the scene as a perfectly formed MobX codebase. I suffered without it for a long time and it was a pretty painful and lengthy PR of 50 commits across 93 changed files to do nothing but make state management not suck.
Escape hatches are dope.
The fact that my GraphQL store foundation classes give the implementor the opportunity to transmogrify each item as it's read or written made it easy to re-use that infrastructure for the UserPreferencesStore
. Architecture should be opinionated and purpose-built; further, offering tightly-scoped escape hatches greatly enhances reusability. JavaScript's Functional-ish pervasive use of lambdas makes it a lot easier to write stateless transform functors for just this purpose.
(And yes, just because it should be stateless doesn't prevent anyone from passing in something more complex, for better or worse, but C++ has the same problem. Ah well.)
« Details: API implementation | The WarmAndFuzzy mobile app » |