synacor / wiretie
- пятница, 9 июня 2017 г. в 03:11:59
JavaScript
A Higher Order Component for Preact that resolves (async) values from a model and passes them down as props.
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:
pending
and rejected
propscomponentDidMount()
methodsAt a high level, the process for working with wire()
works like this:
pending
and rejected
properties to respond to unresolved promises and error conditions (info)<Provider>
wire()
the view up to the model
wire(name)
connects to context[name]
(<Provider name={..}>
)mapToProps
) is the "wiring"
prop: ['foo', {}]
a:['b','c']
is like a = await b('c')
mapModelToProps
) lets you map your model instance to view props
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: '⚠️' } }
⏱ Usepending
to show a "loading" UI for some or all props.
💥 Userejected
to respond to per-prop or overall error states.
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.
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
.
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>
))
// 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.
@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>
);
}
}
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} />
}
}
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>
)
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-model';
export default class App extends Component {
hardware = hardwareModel();
render() {
return (
<Provider hardware={this.hardware}>
<App />
</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';
// 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>
Creates a higher order component 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 of context
is used.mapToProps
(Object | Function)? Maps incoming props to model method call descriptors: ['method.name', ...args]
(optional, default {}
)mapModelToProps
Function? Maps model properties/methods to props: model => ({ prop: model.property })
(optional, default noop
)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);
// 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
Props passed to your wrapped component.
A refresh()
method is passed down as a prop.
Invoking this method re-fetches all data props, bypassing the cache.
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
.
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
.