synacor/wiretie
A Higher Order Component for Preact that resolves (async) values from a model and passes them down as props.
repo name | synacor/wiretie |
repo link | https://github.com/synacor/wiretie |
homepage | |
language | JavaScript |
size (curr.) | 578 kB |
stars (curr.) | 509 |
created | 2017-06-06 |
license | BSD 3-Clause “New” or “Revised” License |
Wiretie is a Higher Order Component for Preact that resolves (async) values from a model and passes them down as props. It lets you wire()
components up to data sources.
This provides a uniform and streamlined way to write async components that avoids complex side effects within componentDidMount()
, and encourages proper instantiability.
Here’s what it does for your code:
Features
- Standalone and library-agnostic, works with any model
- Uses the component hierarchy to avoid singletons and duplication
- Automatically re-renders your component with resolved data
- Maps props to model methods, with optional transformation
- Provides Promise status as
pending
andrejected
props - Intelligently reinvokes methods when mapped prop values change
- Replaces tricky side effecting
componentDidMount()
methods - Safely abstracts access to context
Overview
At a high level, the process for working with wire()
works like this:
- Write a model:
- Make sure it’s a factory, with parameters for things like configuration
- Make sure the returned methods have logical inputs and outputs
- Write tests for it, perhaps publish it to npm
- Write a pure “view” component:
- Data should be plain objects passed in as props
- It doesn’t have to be a function, but it should work like one
- Use state where appropriate: for controlling the view
- Use the
pending
andrejected
properties to respond to unresolved promises and error conditions (info)
- Instantiate & expose the model:
- Invoke the model (factory) with any options
- Store the instance as a Component property or a global
- Expose it into context using a
<Provider>
wire()
the view up to the modelwire(name)
connects tocontext[name]
(<Provider name={..}>
)- The 2nd argument (
mapToProps
) is the “wiring”- Keys are the prop names to pass to the view, values are functions to call on the model
- Pass args to model functions:
prop: ['foo', {}]
- 💭
a:['b','c']
is likea = await b('c')
- 3rd argument (
mapModelToProps
) lets you map your model instance to view props- Useful for mapping model methods to event handler props
- That’s it!
Handling Loading & Errors
In addition to passing mapped data down as props, wiretie
also passes special pending
and rejected
props.
If any promises are still being waited on, the prop names they are mapped to will be keys in a pending
object.
Similarly, if any promises have been rejected, their corresponding prop will be a key in rejected
with a value matching rejection value of the promise.
The pending
and rejected
props are undefined
if there are no promises in that state.
This means if you only need to know if anything is loading, you can just check if (props.pending)
.
The following example shows states for two properties:
const Demo = wire(null, {
foo: Promise.resolve('✅'),
bar: Promise.reject('⚠️')
})( props => {
console.log(props);
})
render(<Demo />)
// logs:
{ pending: { foo: true, bar: true } }
// ... then:
{ foo: '✅', rejected: { bar: '⚠️' } }
⏱ Use
pending
to show a “loading” UI for some or all props.💥 Use
rejected
to respond to per-prop or overall error states.
Usage
The signature for wire()
consists of three arguments, all of which are optional (they can be null
).
wire(
// the property in context where a model instance exists
<String> contextNamespace,
// maps incoming props to model method call descriptors
<Function|Object> mapToProps,
// maps model properties/methods to props
<Function> mapModelToProps
)
See Full
wire()
documentation for parameter details.🤓 Want to dive straight in? Start with this Wiretie Codepen Example.
Usage With Functional Components
Simple connected view
const Username = wire('user', {
// pass resolved value from user.getUsername() down as a "username" prop:
username: 'getUsername'
})( props =>
<span>
{props.username}
</span>
))
💁 Note: for the first render,
props.username
will beundefined
.The component will re-render when getUsername() resolves, passing the value down as
props.username
.
Handling pending state
Let’s show an indicator while waiting for username
to resolve:
const Username = wire('user', {
username: 'getUsername'
})( props =>
<span>
{ props.pending ? 'Loading...' : props.username }
</span>
))
Splitting things apart
// A pure "view" component:
const Username = props => (
<span>
{ props.pending ? (
// display a spinner if we are loading
<Spinner />
) : props.rejected ? (
// display error message if necessary
`Error: ${props.rejected.username}`
) : (
// display our data when we have it
props.username
}
</span>
);
// bind the "view" to the model:
const MyUsername = wire('user', {
username: 'getUsername'
})(Username)
💁 Notice we’ve added error handling to the example.
Usage with Classful Components
@wire('user', { username: 'getUsername' })
class Username extends Component {
render(props) {
return (
<span>
{ props.pending ? (
// display a spinner if we are loading
<Spinner />
) : props.rejected ? (
// display error message if necessary
`Error: ${props.rejected.username}`
) : (
// display our data when we have it
props.username
}
</span>
);
}
}
Event handlers for mutation
const mapUserToProps = user => ({
onChange(e) {
user.setUsername(e.target.value)
}
})
@wire('user', { username: 'getUsername' }, mapUserToProps)
class Username extends Component {
render({ username, onChange }) {
return <input value={username} onInput={onChange} />
}
}
Thinking in MVC / MVVM?
Let’s see the example rewritten using that terminology:
const View = props => (
<span>
{props.username}
</span>
);
const viewModel = wire('user', { username: 'getUsername' });
const Controller = viewModel(View)
render(
<Provider user={new UserModel()}>
<Controller />
</Provider>
)
Tutorial
A “Hardware” Model
We’re going to build a model that provides access to some computer hardware, in this case your battery level.
Models are just factories: their internals can vary (it doesn’t matter). The only constraint is that they accept configuration and return a (nested) object.
Note: This library actually doesn’t prescribe any of the above, it’s just recommended to get the best results in conjunction with
wire()
.
Then, we’ll wire that model’s battery.getLevel()
method up to a component.
Normally, this would require defining a componentDidMount()
method that calls a function
(from … somewhere?), waits for the returned Promise to resolve, then sets a value into state.
Using wire()
though, we don’t need lifecycle methods or state at all.
We also don’t need to invent a way to instance and access our model (often a singleton).
First, we’ll build a model, hardware.js
:
// A model is just a factory that returns an object with methods.
export default function hardwareModel() {
return {
battery: {
// Methods return data, or a Promise resolving to data.
getLevel() {
return navigator.getBattery()
.then( battery => battery.level );
}
}
};
}
Then, we write our simple “view” Component, battery-level.js
:
import { h, Component } from 'preact';
export default class BatteryLevel extends Component {
render({ batteryLevel='...' }) {
// On initial render, we wont have received data from the Hardware model yet.
// That will be indicated by the `batteryLevel` prop being undefined.
// Thankfully, default parameter values take effect when a key is undefined!
return <div>Battery Level: {batteryLevel}</div>
}
}
Now we need to instance our model and expose it to all components using Provider
.
Provider
just copies any props we give it into context
.
Somewhere up the tree (often your root component or an app.js
):
import { h, Component } from 'preact';
import Provider from 'preact-context-provider';
import hardwareModel from './hardware';
import BatteryLevel from './battery-level';
export default class App extends Component {
hardware = hardwareModel();
render() {
return (
<Provider hardware={this.hardware}>
<BatteryLevel />
</Provider>
);
}
}
Now we just have to wire that up to our view! Back in battery-level.js
:
Note the first argument to wire()
is the namespace of our model in context - defined by the prop name passed to <Provider>
.
import { h, Component } from 'preact';
import wire from 'wiretie';
// Descendants of <Provider /> can subscribe to data from the model instance:
@wire('hardware', { batteryLevel: 'battery.getLevel' })
export default class BatteryLevel extends Component {
render({ batteryLevel='...' }) {
// On initial render, we wont have received data from the Hardware model yet.
// That will be indicated by the `batteryLevel` prop being undefined.
// Thankfully, default parameter values take effect when a key is undefined!
return <div>Battery Level: {batteryLevel}</div>
}
}
Finally, render the app!
import { h, render } from 'preact';
import App from './app';
render(<App />);
// Our app will first render this:
<span>Battery Level: ...</span>
// ...then automatically re-render once the Promise resolves:
<span>Battery Level: 1</span>
API
Table of Contents
wire
Creates a higher order component (HOC) that resolves (async) values from a model to props.
This allows (but importantly abstracts) context access, and manages re-rendering in response to resolved data.
wire()
is simply a formalization of what is typically done as side-effects within componentDidMount()
.
Parameters
contextNamespace
String? The context property at which to obtain a model instance. If empty, all ofcontext
is used.mapToProps
(Object | Function)? Maps incoming props to model method call descriptors:['method.name', ...args]
mapModelToProps
Function? Maps model properties/methods to props:model => ({ prop: model.property })
Examples
// resolves news.getTopStories(), passing it down as a "stories" prop
let withTopStories = wire('news', {
stories: 'getTopStories'
});
export default withTopStories( props =>
<ul>
{ props.stories.map( item =>
<li>{item.title}</li>
) }
</ul>
);
// resolves a news story by ID and passes it down as a "story" prop
let withStory = wire('news', props => ({
story: ['getStory', props.id]
}));
// Simple "view" functional component to render a story
const StoryView = ({ story }) => (
<div class="story">
<h2>{story ? story.title : '...'}</h2>
<p>{story && story.content}</p>
</div>
);
// Wrap StoryView in the loader component created by wire()
const Story = withStory(StoryView);
//Get access to the wrapped Component
Story.getWrappedComponent() === StoryView; // true
// Provide a news model into context so Story can wire up to it
render(
<Provider news={newsModel({ origin: '//news.api' })}>
<div class="demo">
<h1>News Story #1234:</h1>
<Story id="1234" />
</div>
</Provider>
);
Returns Function wiring(Child) -> WireDataWrapper. The resulting HOC has a method getWrappedComponent()
that returns the Child that was wrapped
props
Props passed to your wrapped component.
refresh
A refresh()
method is passed down as a prop.
Invoking this method re-fetches all data props, bypassing the cache.
rejected
If any Promises have been rejected, their values are available in a props.rejected
Object.
If there are no rejected promises, props.rejected
is undefined
.
Type: (Object<Error> | undefined)
pending
If any Promises are pending, the corresponding prop names will be keys in a props.pending
Object.
If there are no pending promises, props.pending
is undefined
.
Type: (Object<Boolean> | undefined)