Skip to content

Instantly share code, notes, and snippets.

@a-laughlin
Last active August 15, 2017 09:21
Show Gist options
  • Save a-laughlin/da5018fdef62a3fa7bca1d234ed5cc28 to your computer and use it in GitHub Desktop.
Save a-laughlin/da5018fdef62a3fa7bca1d234ed5cc28 to your computer and use it in GitHub Desktop.
Hyper-Composable React Architecture
// This is a file from an experiment in hyper-decoupled, hyper-composable React architecture.
//
// A lot of it will seem really odd unless you're familiar with Higher Order Components/Functions.
//
// Some highlights:
// - Every component is a single semantic HTML element composed together with behaviors,
// attributes, styles, content, data, etc.
// - Every component is exported for reuse.
// - Any component may make any other(s) its child(ren), in any order.
// - Components get their data from graphql and redux state, so are usually standalone.
// - There is no css stylesheet other than a reset. See the styles section for more.
// - all functions take either 0 or 1 arguments, or are pipes that compose n arguments
//
// For the most part, it's a fully functional, super composable UI, with close to zero
// - nested scopes
// - nested prototypes
// - nested object properties
// - nested elements
// - nested logic
// - css selectors
// - element attributes
// - wrapper elements
// - multi-argument functions
// ... coupling things together
//
// While still experimental, it's hands down the most productive architecture I've worked with.
// I'm creating a writup and accompanying screencast on it. This file is from the working demo.
// Happy to demo it in person.
// - Adam
/* eslint-disable no-unused-vars */
import {withProps,mapProps,defaultProps} from 'recompose';
import React from 'react'; // for examples with jsx, though jsx is unnecessary with per-node HOC composers
import {
withItems, plog, toObj, toDataProp, pipeEach,
sort, from, withStyles, withItemContextStyles, when, withReduxData, ensureArray,
pipeClicks, pipeChanges,toRedux, fromPrimitiveItemProps, toItemProps, toItemsProps,fromEventTargetText,
fromEventTargetValue,toggleInRedux,is,len0, whenLoading,whenError,whenLoaded,ifElse,isLoading,
listBaseStylesHOCFactory, itemBaseStylesHOCFactory, lorem, pickValue,pickValues,groupByArray,
Div,Span,Ul,Li,Input,A,Label,TextInput
} from './repiped';
import {gql,graphql as withGQLData} from 'react-apollo';
import {loadRandomNorrisQuote} from './api';
import {
pick, pipe, capitalize, startCase, get, map, sortBy, filter, identity, over,flatMap, uniq, cond,
conforms,intersection,assign,stubTrue,compose,keys
} from 'lodash/fp';
import {
list as listStyles, text as textStyles, border as borderStyles, sizing as sizingStyles
} from './styles.js';
// Styles (favoring abbreviations over abstraction for learnability)
// Note: I don't love the abbreviations yet. Still getting used to styletron. Created them for the demo.
const {
/* flexbox align */ lAIS,lAIC,lAIE, /* justify */ lJCS,lJCC,lJCE,lJCB,lJCA,
/* list styles */ lVertical,lHorizontal,lGrid,lVerticalItem,lHorizontalItem,lGridItem,
/* list colors */ lc3,lc7,lcD,lcE,lcF,lcNone
} = listStyles;
const {bc3,bcC,bSolid,bDashed,bRound,bt0,b1px,bb0p2,bt1px,bb1px} = borderStyles;
const {tSans,tSerif,t0p8,t1,t1p2,tUnderline,tcFFF,tc0,tc3,tc6,tc9,tcB,tcE} = textStyles;
const {
/*margin*/ mb0,mr0,m0p2,mr0p2,mt0p2,mb0p2,mr0p5,ml0p5,mt0p5,mb0p5,m0p5,m1,ml1,mr1,mt1,mb1,mlAuto,
/*width*/ w6,w8,w10,w200px,wAuto,w70pct,w100pct, /*height*/ h1p6, /*zIndex*/ z1,z2,z3
} = sizingStyles;
/**
* UIs are composed of lists of vertical and horizontal components
*
* Each component is wrapped to be a Higher Order Component (HOC) composer
* const Div = (...fns) => compose(...fns)('div');
*
* withItems is a HOC that provides a consise, declarative syntax to transform inputs like text,
* components, and data into list items.
*
* Notes:
* - Any of its behaviors are easy to override via withItemsHOCFactory()
* - It works well with Ramda and lodash/fp functions (the examples use lodash/fp extensively)
* - It encourages exportable components and a flat html structure.
* - All nodes are presentational. There are no wasted nodes.
* - Flexbox always works.
* - It is synchronous.
* Basic operation:
* - Non-functions are queued
* - Consecutive functions are reduced and their output queued
* - The queue gets run through createElement, flattened, then added to props.children.
* - It sounds weird, but is a lot faster to work with than manual JSX.
* Arguments to piped functions: (last, flattenedProps)
* - flattenedProps flattens data props, resulting in {...props,...props.data-a,...props.data-b}
* - flattenedProps decouples piped fns from the implementation of data-providng HOCs like
* react-redux connect(), and graphql(), and some utility functions
*
* Simple examples below. withItems is used in more complex ways throughout the demo
*/
// HTML Nodes as HOC Composers
// const Div = (...fns) => compose(...fns)('div');
export const ItemsHeading = Div(withItems(`withItems: The Unstyled First Heading.`));
export const Items1 = Div(withItems('Text!'));
// While any non-function won't get piped, arrays make it explicit
export const Items2 = Div(withItems(['More Text! In an Array!']));
// withItems is just a fancy wrapper around createElement, so any valid React component is fine.
// The exception: strings like 'div' are treated as text, because blank nodes are low priority.
export const SomeComponent = props=><div {...props}>Stateless Functional Component!</div>
export const Items3 = Div(withItems([SomeComponent]));
// function outputs get piped
export const BlankInput = props=><input {...props} />
export const Items4 = Div(withItems(()=>({defaultValue:'Piped Text!'}),BlankInput));
// const wItems3 = Div(withItems(SomeComponent));
// Fails! The flattened "data-" props are invalid html elements properties
export const ItemsContent = Div( withItems([Items1, Items2, Items3, Items4]) );
/**
* Styles
*
* withStyles is an HOC for all the styles needed to display a component.
* It intentionally does not support positional/sizing styles like width, height, margin,
* flex, alignSelf, flexGrow, flexShrink. Those go in the parent list's withItemContextStyles,
* so they will apply to the item only when in that list.
* Another way to think about it is with React components:
* - Components can set their own default props and alter childrens' props.
* - Lists can set their own default styles and alter items' styles.
*
* withItemContextStyles is where all the styles go that only make sense in the current context.
* Usually those are the same positional styles that withStyles doesn't support.
* The benefit is super reusable components that render anywhere at any size.
* The tradeoff is time designing ... components that render anywhere, at any size.
* Fortunately, it's a good candidate for incremental improvements. All applicable styles are
* directly on the element, so there's no hunting for them.
*/
export const StylesHeading = Div(
withItems(`Styles`,`Styletron`),
withStyles(lGrid,lcD,tc3, lJCB, t1p2, bc3, bSolid, bt1px,bb1px),
withItemContextStyles(lGridItem)
);
export const StylesContent = Div(
withItems('Styles'.split('')),
withStyles(lVertical),
withItemContextStyles(lVerticalItem,{n1:tc0,n2:tc3,n3:tc6,n4:tc9,n5:tcB,n6:tcE})
);
/**
* Style Shorthands
*
* Styles are rarely where bugs originate, so it's useful to not have to see them when debugging.
* We can minimize their visual footprint through shorthands.
* WithStyles returns a new hoc when you pass it styles objects, so you can call
* the HOC as a function to pass it new styles.
*
*/
const v = withStyles(lVertical);
const h = withStyles(lHorizontal);
const g = withStyles(lGrid);
// or map and destructure instead of typing withStyles every time
const [vi,hi,gi] = map(withItemContextStyles)([lVerticalItem,lHorizontalItem,lGridItem]);
// or compose in some other styles for fast wireframing
// const [vi,hi,gi] = map(
// ([color,itmDir])=>withItemContextStyles({borderColor:color},itmDir,tc3,b1px,bDashed)
// )([['blue',lVerticalItem],['red',lHorizontalItem],['darkgreen',lGridItem]]);
// call a styles hoc with more styles to get a new hoc with the new styles merged in
const headingListStyles = g(lJCB,lcD,tc3, lJCB, t1p2, bc3, bSolid, bt1px, bb1px)
// composing HOCs is easy since they're just functions
const headingStyles = compose(gi, headingListStyles);
// and composing them with components is easy with HOC composers like Div()
export const StylesShortHeading = Div(
withItems(`Style Shorthands`,`Flexbox: Vertical, Horizontal, Grid`),
headingStyles
);
// some grid, vertical, and horizontal flexbox examples.
const wStylesItems = withItems('Styles'.split(''));
const itemStyles = {n1:tc0,n2:tc3,n3:tc6,n4:tc9,n5:tcB,n6:tcE};
export const StylesG = Div(wStylesItems, g, gi(itemStyles));
export const StylesV = Div(wStylesItems, v, vi(itemStyles));
export const StylesH = Div(wStylesItems, h, hi(itemStyles));
export const StylesShortContent = Div(withItems([StylesV, StylesG, StylesH]), v(t0p8), vi({n2:w100pct},mt1));
/**
* Connect()ing Redux Data
*
* withReduxData:
* - Wraps react-redux connect's(...) mapStateToProps.
* - Provides shortcuts to reduce boilerplate and improve code signal to noise ratio for learnability
* - Prevents invalid props errors from auto-injecting dispatch into string (e.g. 'div') components.
*/
export const ReduxDataHeading = Div(withItems('Connect()ing Redux Data'), headingStyles);
export const ReduxDataLabel = Div(withItems(`1 way Data Bind (withReduxData)`));
export const ReduxDataDiv = Div(withReduxData('reduxDataExample'),withItems(from('reduxDataExample')));
export const ReduxDataContent = Div(withItems([ ReduxDataLabel, ReduxDataDiv ]),v,vi(mb0p5));
/**
* Event Handler Pipes
* pipe any event to any destination other than the element's own props
* destinations are limited only by the function passed in. toRedux, toGraphql, toApi, wherever...
*
* arguments received are pipe-ish with an extra arg (last, sources), where:
* last is the last piped output
* sources is {...props,event};
*/
export const EventPipeHeading = Div(withItems(`Event Handler Pipes`), headingStyles);
export const TwoWayLabel = Span(withItems('2 way Data Bind (withReduxData + toRedux)'));
export const TwoWayInput = TextInput(
withReduxData({value:'reduxDataExample'}),
pipeChanges(fromEventTargetValue, toRedux('reduxDataExample')),
);
export const TwoWayBindingSection = Div(withItems([TwoWayLabel,TwoWayInput]),g(lJCB),gi(mt0p5,mb0p5));
// how often do you refer to your html page? Or stylesheet, or
const rdxNorris = 'norrisContent';
export const NorrisText = Div(withReduxData(rdxNorris), withItems(from(rdxNorris)), h(t0p8),hi(m1));
// handler pipes take both sync and async operations
export const NorrisBtn = Div(
pipeClicks('Loading!',toRedux(rdxNorris),loadRandomNorrisQuote,toRedux(rdxNorris)),
withItems('Load a Norris Quote!'),
v(lcD,bRound,{cursor:'pointer'}),
vi(m0p5),
);
export const NorrisSection = Div(
withItems([NorrisBtn, NorrisText]),
vi(mt1,{last:mb1}),
v(lAIC),
);
export const EventPipeContent = Div(withItems([TwoWayBindingSection,NorrisSection]),vi(w100pct),v);
/**
* Filterable Movies
*/
const FilterableHeading = Div(withItems('Filterable Content','GraphQL (with mocks)'), headingStyles);
/* MovieFilterFilterForm Row 1 */
const DropDownRow = Span(hi(m0p2,lAIC), h(lJCS, t0p8,{cursor:'pointer',first:t1}));
const withCheckedStyles = withStyles({before:{content:'"\\2705"',flex:'0', alignSelf:'center'}});
const withNonCheckedStyles = withCheckedStyles({before:{content:'"\\2700"'}});
const toCheckedRow = predicate => cond([ // conditional styles could be more intuive, but this works for now
[predicate,toItemProps(withCheckedStyles(withItems(fromPrimitiveItemProps)(DropDownRow)))],
[stubTrue,toItemProps(withNonCheckedStyles(withItems(fromPrimitiveItemProps)(DropDownRow)))]
]);
const genreSelected = (genres,{filmGenresSelected})=>filmGenresSelected.includes(genres);
export const GenreDropDown = Div(
withGQLData(gql`query {films {genre}}`,{}),
withReduxData('filmGenresSelected'),
pipeClicks(fromEventTargetText, toggleInRedux('filmGenresSelected')),
withItems(['Genre'], from('films'), groupByArray('genre'),keys,sort, pipeEach(toCheckedRow(genreSelected))),
v, vi(lcE,bt1px,bcC,bSolid,t0p8,w100pct,{padding:'0 0.2em',first:[bb0p2,bt0,t1,{cursor:'default'}]}),
);
const yearSelected = (year,{filmYearsSelected})=>filmYearsSelected.includes(year);
export const YearDropDown = Div(
withGQLData(gql`query {films {year}}`),
withReduxData('filmYearsSelected'),
pipeClicks(fromEventTargetText, toggleInRedux('filmYearsSelected')),
withItems(['Year'], from('films'), sortBy('year'), map('year'), uniq, pipeEach(toCheckedRow(yearSelected))),
v, vi(lcE,bt1px,bcC,bSolid,t0p8,w100pct,{padding:'0 0.2em',first:[bb0p2,bt0,t1,{cursor:'default'}]}),
);
const FilmSearch = TextInput(
withReduxData({value:'filmQuery'}),
pipeChanges(fromEventTargetValue, toRedux('filmQuery')),
);
export const GlassedFilmSearch = Div(
withItems([FilmSearch]),
hi(w100pct,{backgroundColor:'transparent'}),
h({after:{...lAIC,display:'inline-flex', marginLeft:'-1.2em', content:'"\\1F50E"'}}),
);
/* MovieFilterFilterForm Row 2 */
export const MediaRadioInput = Input(withProps({type:'radio',name:'media-type'}));
const MediaRadio = (mediaType)=>Div(
pipeClicks(mediaType,toRedux('filmMediaSelected')),
withReduxData('filmMediaSelected'),
withItems(
from('filmMediaSelected'),is(mediaType),toObj('checked'),MediaRadioInput,
[`${capitalize(mediaType)}s`]
),
hi({pointerEvents:'none'},ml0p5),
h({cursor:'pointer'}),
);
export const MovieRadio = MediaRadio('movie');
export const BookRadio = MediaRadio('book');
export const FilmClearItem = A(
pipeClicks({filmYearsSelected:[],filmGenresSelected:[],filmMediaSelected:'',filmQuery:''},toRedux()),
withItems(['Clear Filters']),
hi,h(lJCC,tUnderline,{cursor:'pointer'})
);
export const MovieFilterRow1 = Div(
withItems([GenreDropDown, YearDropDown, GlassedFilmSearch]),
g, gi(wAuto,h1p6,mr0p5,{n1:z3,n2:z2,n3:z1,overflow:'hidden',hover:{overflow:'visible'},first:ml0p5,last:[mlAuto]})
);
export const MovieFilterRow2 = Div(
withItems([MovieRadio, BookRadio, FilmClearItem]), g, gi(wAuto,mr0p5,{first:ml0p5,last:[mlAuto]})
);
export const MovieFilterFilterForm = Div(
withItems([MovieFilterRow1, MovieFilterRow2]), vi(w100pct,mt0p5,{last:mb0p5}), v(bcC,bSolid,b1px)
);
/* Film Grid */
// used in FilmGrid below
function filterFilms ({films,filmYearsSelected,filmGenresSelected,filmMediaSelected,filmQuery}){
return filter(conforms({
year:y => len0(filmYearsSelected) || !len0(intersection(ensureArray(y),filmYearsSelected)),
genre:g => len0(filmGenresSelected) || !len0(intersection(ensureArray(g),filmGenresSelected)),
type:m => !filmMediaSelected || filmMediaSelected === m,
title:t => !filmQuery || t.toLowerCase().includes(filmQuery.toLowerCase()),
}))(films)
}
const FilmTitleYear = Div(withItems(({title,year})=>`${title} (${year})`));
const FilmSummaryBox = Li(
withItems(toItemProps(FilmTitleYear)),
vi(mt0p5,mb0p5,w100pct),
v(t0p8),
);
export const FilmGrid = Div(
withGQLData(gql`query {films {title,year,genre,type,poster}}`,{}),
withReduxData(pick(['filmYearsSelected','filmGenresSelected','filmMediaSelected','filmQuery'])),
withItems(
whenLoading('Loading!'),
whenError('Error!'),
whenLoaded(filterFilms, sortBy('title'), toItemsProps(FilmSummaryBox)),
),
gi(w100pct,{flexShrink:'0',flexGrow:'0',above400px:w200px}),
g(bcC,bSolid,b1px),
);
const FilterableContent = Div(withItems([ MovieFilterFilterForm, FilmGrid]),vi(w100pct),v);
/**
* Reusability (demo rendering components in different nesting/orders)
*/
const ReusabilityHeading = Div(withItems('Reusability: Components in Other Orders'), headingStyles);
const componentOptions = {
Items1,Items2,Items3,Items4, ItemsHeading, ItemsContent,
StylesHeading,StylesContent,
StylesShortHeading, StylesShortContent,StylesG, StylesV, StylesH,
TwoWayLabel, TwoWayInput, TwoWayBindingSection,
NorrisText, NorrisBtn, NorrisSection,
EventPipeContent, EventPipeHeading,
GenreDropDown, YearDropDown, FilmSearch, GlassedFilmSearch, MovieFilterRow1,
MovieRadio, BookRadio, FilmClearItem, MovieFilterRow2,
MovieFilterFilterForm, FilmGrid
};
const allComponentKeys = ()=>Object.keys(componentOptions);
const componentSelected = (name,{displayedComponents})=>displayedComponents.includes(name);
const DisplayedComponentSelector = Div(
withReduxData('displayedComponents'),
pipeClicks(fromEventTargetText,toggleInRedux('displayedComponents')),
withItems(['Components'],allComponentKeys,pipeEach(toCheckedRow(componentSelected))),
v, vi,
);
const DisplayedComponentsGrid = Div(
withReduxData('displayedComponents'),
withItems(from('displayedComponents'),pickValues(componentOptions)),
g(lAIS,lJCS), gi
);
const ReusabilityContent = Div(
withItems([DisplayedComponentSelector,DisplayedComponentsGrid]),
g(lAIS,{above400px:{flexWrap:'nowrap'}}), gi({last:[w100pct,ml1]})
);
/**
* App
*/
export const App = Div(
withItems([
ItemsHeading, ItemsContent,
StylesHeading,StylesContent,
StylesShortHeading, StylesShortContent,
ReduxDataHeading,ReduxDataContent,
EventPipeHeading, EventPipeContent,
FilterableHeading, FilterableContent,
ReusabilityHeading, ReusabilityContent,
]),
vi(tSans,mt1,w100pct,{above600px:{padding:'0 15%'}}),
v(lAIC,{fontSize:'12px',above400px:{fontSize:'14px'},above600px:{fontSize:'16px'}})
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment