Designing a Flexible UI Architecture
with special guests React and GraphQL
@kamranayub
background by saragnzalez
To design a flexible UI:
1. Find your common UI Patterns
2. formalize them into a schema
3. drive your ui from it
A "Schema-driven" UI
business RULES
handles
RENDERING hints
VALIDATION hints
Semantics
A "Schema-driven" UI
provides a declarative way to render your UI that separates business logic from rendering logic
Item data
Item data is complex
Managing item data
Challenges
complex ui state
Dynamic business rules
two different editing modes
scale to manage 200+ items at once
Horst Rittel and Melvin Webber defined a "wicked" problem as one that could be clearly defined only by solving it, or by solving part of it. This paradox implies, essentially, that you have to "solve" the problem once in order to clearly define it and then solve it again to create a solution that works.
Code Complete, Steve McConnell
Find Common UI Patterns
Find common UI patterns
Find common UI patterns
[
{},
{},
{}
]
Find common UI patterns
[
{
id: "product_title",
display_name: "Product Title"
},
{
id: "street_date",
display_name: "Street Date"
},
{
id: "genres",
display_name: "Genres"
}
]
Find common UI patterns
[
{
id: "product_title",
display_name: "Product Title",
component_type: "single_line_text"
},
{
id: "street_date",
display_name: "Street Date",
component_type: "date"
},
{
id: "genres",
display_name: "Genres",
component_type: "multi_select"
}
]
Find common UI patterns
[
{
id: "product_title",
display_name: "Product Title",
component_type: "single_line_text",
min_length: 0,
max_length: 1337
},
{
id: "street_date",
display_name: "Street Date",
component_type: "date",
min_date: 1569861733,
max_date: null
},
{
id: "genres",
display_name: "Genres",
component_type: "multi_select",
min_values: 1,
max_values: 3
}
]
Find common UI patterns
{
id: "product_title",
display_name: "Product Title",
component_type: "single_line_text",
notes: [
"Not editable by VENDOR role"
]
}
{
id: "street_date",
display_name: "Street Date",
component_type: "date",
notes: [
"Must be today or future"
]
}
- PO and UX could update
- Structured metadata
- Included human readable notes like business rules or "gotchas"
Find common UI patterns
{
id: "product_title",
display_name: "Product Title",
component_type: "single_line_text",
min_length: 0,
max_length: 1337,
notes: [
"Not editable by VENDOR role"
]
}
{
id: "product_title",
display_name: "Product Title",
component_type: "single_line_text",
min_length: 0,
max_length: 1337,
read_only: false,
notes: [
"For VENDOR role, READ_ONLY is TRUE"
]
}
rethinking item data
rethinking item data
item
product_title
street_date
rethinking item data
{
"product_title": "Pokemon Sword",
"street_date": "2019-11-15T00:00:00Z"
}
This is a "consumer" way of thinking
How do we describe data?
Formalize into a schema
Data Schema
product_title
rules
display
values
street_date
rules
display
values
Data schema
{
"product_title": {
"display_name": "Product Title",
"description": "A product's title",
"default_rules": {
"required": true,
"min_length": 1,
"max_length": 1337,
"read_only": false
},
"display": {
"component_type": "single_line_text",
"help_text": null
},
"item_values": [
{
"id": "item1",
"value_raw": "Pokemon Sword",
"value_human": "Pokemon Sword"
},
{
"id": "item2",
"value_raw": "Pokemon Sword (Digital Edition)",
"value_human": "Pokemon Sword (Digital Edition)",
"override_rules": {
"required": false,
"read_only": true
}
}
]
},
"street_date": {
"display_name": "Street Date",
"description": "The date this item will be available for purchase",
"default_rules": {
"required": true,
"min_date": 1555818103157,
"max_date": null,
},
"display": {
"component_type": "date",
"help_text": "Must be later than today's date"
},
"item_values": [
{
"id": "item1",
"value_raw": "2019-11-15T00:00:00Z",
"value_human": "Nov 15, 2019",
"override_rules": {
"min_date": null
}
}
]
}
}
Drive ui from the schema
primary
data
external data
caching layer
GraphQL Backend
schema
engine
data
resolution
React Frontend
state
management
client rule engine
High-level architecture
Why graphql?
GraphQL is a specification
which provides
predictability
consistency
safety
Why graphql?
type ItemAttribute {
id: String!
display: AttributeDisplay
}
type AttributeDisplay {
component_type: ComponentType!
}
enum ComponentType {
single_line_text
multi_line_text
number
date
datetime
multi_option
}
Static typing
Powerful query capabilities
query data($attribute_ids: [String!]!, $item_ids: [String!]!, hydrate: Boolean!) {
attributes(ids: $attribute_ids) {
id
title: description
default_rules {
max_length
}
values(items: $item_ids) {
raw_value
human_value @include(if: $hydrate)
override_rules {
max_length
}
}
}
item_metadata(ids: $item_ids) {
last_updated
}
}
Why graphql?
type AttributeRules {
required: Boolean
read_only: Boolean
max_length: Int
max_chars: Int @deprecated(reason: "Use `max_length` instead")
}
One version, field-level reporting
subscription item_updates(ids: ["item1"]) {
item_id
values {
raw_value
human_value
}
}
Subscriptions
Why graphql?
Plus
Caching infrastructure
Field-level timing and analytics
Custom directives
Extensible architecture
But seriously,
you could totally use REST too
Data resolution
query {
itemData(attribute_ids: ["genres"]) {
attributes {
id
default_rules {
required
read_only
}
display {
component_type
}
values(item_ids: ["item1", "item2"]) {
item_id
raw_value
human_value
override_rules {
required
}
}
}
}
}
Load raw data
Load external data
Map the attributes
Data resolution
Mapping the attributes
genres
export default {
id: "genres",
async fetchData(context) {
const { itemIds, services } = context;
// fetch from db
const itemData = await services.db.getItems(itemIds);
return itemData.map(data => ({
item_id: data.id,
raw_value: data.genres.map(genre => genre.genre_id);
}))
}
}
Data resolution
Fetching raw and human data
genres
Database
[1, 3]
External
APIs
["Adventure", "Role-Playing Game"]
Data resolution
Fetching raw and human data
genres
title
Database
per-request cache
Data resolution
genres
platforms
brands
publisher
...
Fetching raw and human data
Caching layer
Service 1
Service 2
Schema Engine
{
id: "genres",
default_rules: {
required: true,
read_only: false
},
display: {
component_type: "multi_select"
},
values: [
{
item_id: "item1",
raw_value: 2,
human_value: "Adventure",
override_rules: null
}
]
}
Merge in values
Run rules
Tweak schema
Finalize
Base schema
Schema engine
Base schema
{
"product_title": {
"display_name": "Product Title",
"description": "A product's title",
"default_rules": {
"required": true,
"min_length": 1,
"max_length": 1337,
"read_only": false
},
"display": {
"component_type": "single_line_text",
"help_text": null
}
},
"street_date": {
"display_name": "Street Date",
"description": "The date this item will be available for purchase",
"default_rules": {
"required": true,
"min_date": 1555818103157,
"max_date": null,
},
"display": {
"component_type": "date",
"help_text": "Must be later than today's date"
}
}
}
Schema Engine
{
id: "genres",
default_rules: {
required: true,
read_only: false
},
display: {
component_type: "multi_select"
},
values: [
{
item_id: "item1",
raw_value: 2,
human_value: "Adventure",
override_rules: null
}
]
}
{
id: "genres",
default_rules: {
required: true,
read_only: false
},
display: {
component_type: "multi_select"
},
values: [
{
item_id: "item1",
raw_value: 2,
human_value: "Adventure",
override_rules: {
required: false,
read_only: true
}
}
]
}
Schema Engine
Business rules run on the server and are represented in simple terms to the client
primary
data
external data
caching layer
GraphQL Backend
schema
engine
data
resolution
React Frontend
state
management
client rule engine
High-level architecture
React UI
Schema rendering
undo / rollback values
relationship semantics
multi-item handling
Schema rendering
{
"attributes": [
{
"id": "product_title",
"display": {
"component_type": "single_line_text"
}
}
]
}
import * as Components from './componentTypes';
function renderField(componentType, props) {
if (Components[componentType]) {
return React.createElement(
Components[componentType],
props
);
}
throw new Error(`Unknown component type: ${componentType}`)
}
SCHEMA rendering
{
"attributes": [
{
"id": "product_title",
"default_rules": {
"read_only": true
},
"display": {
"component_type": "single_line_text"
},
"item_values": [
{
"id": 1,
"raw_value": "Pokemon Sword",
"human_value": "Pokemon Sword"
}
]
}
]
}
function SingleLineText(props) {
const { read_only, raw_value, human_value } = props;
const [value, setValue] = React.useState(raw_value);
const handleChange = React.useCallback(e => {
setValue(e.target.value);
});
if (read_only) {
return (<div>{human_value}</div>);
}
return (
<input type="text" value={value} onChange={handleChange} />
)
}
SCHEMA rendering
{
"attributes": [
{
"id": "product_title",
"default_rules": {
"read_only": true,
"max_length": 50
},
"display": {
"component_type": "single_line_text"
}
}
]
}
SCHEMA rendering
{
"attributes": [
{
"id": "genres",
"display": {
"component_type": "multi_select"
}
}
]
}
Schema rendering
query {
lookup(attribute_id: "genres") {
page_size
total
options(filter: "act") {
raw_value
human_value
}
}
}
Undo / Rollback
review before save
Quick data entry
Batched save
Undo / Rollback
Action
Time
Action
Action
Change
Change
Undo / Rollback
{
"item1": {
"product_title": [
{
"raw_value": "version 3", "human_value": "version 3"
},
{
"raw_value": "version 2", "human_value": "version 2"
}
]
}
}
Relationship semantics
Relationship semantics
Taxonomy
Attribute
Group
"Editor"
e.g. product_title
e.g. dimensions (WxHxD)
e.g. complex attributes
Relationship semantics
prevent updates
Relationship semantics
Hierarchical / Source
Relationship semantics
Conditional
Graph theory and math comes in handy!
Relationship semantics
Schema representation
{
"id": "primary_genre",
"relationships": [
{ "type": "hierarchy", "related_id": "genres" }
]
}
{
"id": "genres",
"relationships": [
{ "type": "blocking", "related_id": "product_title" }
]
}
{
"id": "number_of_players",
"relationships": [
{ "type": "conditional", "related_id": "genres", "when": [1], "match": "some" }
]
}
multi-item handling
mixed edits
variedness
payload size
multi-item handling
mixed edits
multi-item handling
Variedness
multi-item handling
Payload size
What about saving data?
{
product_title: [
{ item_ids: ["item1", "item2"], value: "Pokemon Sword" },
{ item_ids: ["item3"], value: "Pokemon Sword (Digital Edition)" }
],
street_date: [
{ item_ids: ["item1", "item2", "item3"], value: "2019-11-15T00:00:00Z" }
]
}
Send
Compare
Validate
Persist
Publish
The save flow reuses much of the same schema infrastructure
Async and optimistic
To design a flexible UI:
1. Find your common UI Patterns
2. formalize them into a schema
3. drive your ui from it
Where can you start?
First, decide whether this is something you'll find value from. Do you have dozens or hundreds of data points to support?
If it doesn't, then roll your own schema
Questions?
Things I didn't talk about:
State management
Testing
Universal rule engine
Universal rule engine
- Runs on both client and server
- Allows you to share logic
- Makes the feedback loop faster
- We didn't do this but I wish we did
State management
- Redux
- Handles merging of server schema and client journal
- Allows us to time-travel debug in user sessions
Testing
@kamranayub
Designing a Flexible UI Architecture with React and GraphQL (MDC 2019)
By Kamran Ayub
Designing a Flexible UI Architecture with React and GraphQL (MDC 2019)
MDC 2019 presentation on flexible UI architecture with React and GraphQL. Recording available soon.
- 878