MVC
Problem
I want to create a rich web app and need a bit more than a view layer.
MVC comes to mind with the following qualities:
- the "view" presents data
- the "bus" channels user input from the view to the controller
- the "controller" collects inputs as "state", calls the "model" with this state to fetch data and renders this data into the view
- the "router" maps input from the url to the controller
- the "app" encapsulates all the above
Solution
Choo provides a simple framework to accomplish this, with a few caveats:
- there's no controller (routes map directly to views), but there is a store/reducer
- the bus is global, not per-route; namespacing events helps reduce noise, eg
emitter.on('posts:like' ...
- the line between "state" and "model" is blury; thinking of it purely as view state helps; extract complex data wrangling to a model layer
- there's no "route" event; shim by firing an initial event and listen for subsequent "navigation" events
Example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
const choo = require('choo')
const html = require('choo/html')
const get = require('lodash.get')
const set = require('lodash.set')
class Model {
constructor(storage){
this.storage = storage
}
async getPosts(userId){
const posts = get(this.storage, `posts`)
return Object.entries(posts).map(([id, post]) => {
post.id = id
post.likes = Object.keys(post.likes || {})
return post
})
}
async setLike(userId, postId){
set(this.storage, `posts.${postId}.likes.${userId}`, true)
}
}
function store(model, state, emitter){
state.userId = '1'
emitter.on('*', console.log)
emitter.on('posts:load', () => {
model.getPosts(state.userId).then(posts => {
state.posts = posts
emitter.emit('render')
})
})
emitter.on('posts:like', postId => {
model.setLike(state.userId, postId).then(() => {
emitter.emit('posts:load')
})
})
}
function postsView(state, emit){
function onLike(postId){
return () => emit('posts:like', postId)
}
const posts = state.posts.map(post => {
return html`
<li>
${post.text}
<span onclick=${onLike(post.id)}>👍 (${post.likes.length})</span>
</li>
`
})
return html`
<body>
<ul>
${posts}
</ul>
</body>
`
}
var app = choo()
const storage = {
posts: {
1: {
text: 'a',
likes: {
2: true
}
},
2: {
text: 'b'
},
3: {
text: 'c'
}
}
}
const model = new Model(storage)
app.use(store.bind(store, model))
app.route('/', postsView)
app.mount('body')
app.emitter.emit('posts:load')
Alternative
A Choo-inspired, but more literal interpretation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
const html = require('bel')
const qs = require('sheet-router/qs')
const EventEmitter = require('events')
const wayfarer = require('wayfarer')
const yo = require('yo-yo')
function createApp(state, win, router, renderer, queryParser, createEmitter){
var render;
return {
use: function(route, controller, view){
router.on(route, params => {
state.route = route
state.params = params
state.query = queryParser(win.location.href)
const emitter = createEmitter()
const emit = emitter.emit.bind(emitter)
emitter.on('render', () => {
render(view(state, emit))
})
emitter.on('navigate', href => {
win.history.pushState({}, state.title, href)
router(win.location.pathname)
})
controller(state, emitter)
})
},
mount: function(el){
render = renderer.update.bind(renderer, el)
router(win.location.pathname)
}
}
}
const model = {
todos: ['a', 'b', 'c']
}
function view(state, emit){
const filter = html`
<input placeholder="Filter" value="${state.query.filter}" onkeyup=${onKeyUp}>
`
function onKeyUp(e){
emit('filter', e.target.value)
}
var items = state.todos.map(todo => html`
<li>${todo}</li>
`)
return html`
<body>
${filter}
<ul>
${items}
</ul>
</body>
`
}
function controller(model, state, emitter){
emitter.on('filter', filter => {
emitter.emit('navigate', `/?filter=${filter}`)
})
const pattern = new RegExp(state.query.filter)
state.todos = model.todos.filter(pattern.test.bind(pattern))
emitter.emit('render')
}
const app = createApp({}, window, wayfarer('/404'), yo, qs,
function(){ return new EventEmitter() })
app.use('/', controller.bind(controller, model), view)
app.mount(document.body)