UNPKG

19.5 kBMarkdownView Raw
1# startupjs react-sharedb
2
3> Run `ShareDB` in `React`
4
5## What it does
6
71. Brings real-time collaboration to React using [ShareDB](https://github.com/share/sharedb);
82. Uses [Racer](https://derbyjs.com/docs/derby-0.10/models) to add a `model`
9 to your app to do any data manipulations;
103. The `model` acts as a global singleton state, so you can use it as a
11 replacement for other state-management systems like `Redux` or `MobX`;
124. Makes the `render` reactive similar to how it's done in `MobX` --
13 rerendering happens whenever any `model` data you used in `render`
14 changes.
15
16## Installation
17
18It's recommended to just use `startupjs` package, since it proxies the API of `@startupjs/react-sharedb`.
19
20```
21yarn add startupjs
22```
23
24## Hooks Usage
25
26### `observer(FunctionalComponent)` HOF
27
28Higher Order Function which makes your functional component rendering reactive.
29**You have to** wrap your functional components in it to be able to use `react-sharedb` hooks.
30
31```js
32import {observer, useDoc} from 'startupjs'
33
34export default observer(function User ({userId}) {
35 let [user, $user] = useDoc('users', userId)
36 return (
37 <input value={user.name} onChange={e => $user.set('name', e.target.value)} />
38 )
39})
40```
41
42### `useDoc(collection, docId)`
43
44Subscribe to the particular Mongo document by id.
45
46`collection` \[String\] -- collection name. Required
47
48`docId` \[String\] -- document id. Required
49
50**Returns:** `[doc, $doc]`, where:
51
52`doc` \[Object\] -- value of document
53
54`$doc` \[Model\] -- scoped model targeting path `collection.docId`
55
56**Example:**
57
58```js
59import React from 'react'
60import { observer, useDoc } from 'startupjs'
61
62export default observer(function Room ({
63 roomId = 'DUMMY_ID'
64}) {
65 let [room, $room] = useDoc('rooms', roomId)
66
67 // If the document with an `_id` of `roomId` doesn't exist yet, we create it.
68 // We have to wait for the document to be created by throwing the promise.
69 if (!room) throw $room.createAsync({ title: 'New Room' })
70
71 function onChange (e) {
72 $room.set('title', e.target.value)
73 }
74
75 return <input onChange={onChange} value={room.title} />
76})
77```
78
79**IMPORTANT:** The id of the document is stored internally in Mongo inside the `_id` field.
80But when it gets into the model, it gets replaced with the `id` field instead, and vice versa.
81
82### `useQuery(collection, query)`
83
84Subscribe to the Mongo query.
85
86`collection` \[String\] -- collection name. Required
87
88`query` \[Object\] -- query (regular, `$count`, `$aggregate` queries are supported). Required
89
90**Returns:** `[docs, $docs]`, where:
91
92`docs` \[Array\] -- array of documents
93
94`$docs` \[Model\] -- scoped model targeting the whole `collection`
95
96**Example:**
97
98```js
99let [users, $users] = useQuery('users', { roomId: props.roomId, anonymous: false })
100```
101
102**IMPORTANT:** The scoped model `$docs`, which you receive from the hook, targets the global collection path.
103You can use it to easily reach a document with a particular id using scoped models:
104
105```js
106let [users, $users] = useQuery('users', { roomId, anonymous: false })
107for (let user of users) {
108 $users.at(user.id).setEach({
109 joinedRoom: true,
110 updatedAt: Date.now()
111 })
112}
113```
114
115### `useQueryIds(collection, ids, options)`
116
117Subscribe to documents in collection by their ids
118
119`collection` \[String\] -- collection name. Required
120
121`ids` \[Array\] -- array of strings which should be document ids.
122
123`options` \[Object\] --
124
125 ```js
126 {
127 reverse: false // reverse the order of resulting array
128 }
129 ```
130
131**Example:**
132
133```js
134export default observer(function Players ({ gameId }) {
135 let [game] = useDoc('games', gameId)
136 let [players, $players] = useQueryIds('players', game.playerIds)
137
138 return (
139 <div>{players.map(i => i.name).join(' ,')}</div>
140 )
141})
142```
143
144### `useQueryDoc(collection, query)`
145
146Subscribe to a document using a query. It's the same as `useDoc()`, but
147with `query` parameter instead of the particular `docId`.
148`$limit: 1` and `$sort: { createdAt: -1 }` are added to the query automatically (if they don't already exist).
149
150`collection` \[String\] -- collection name. Required
151
152`query` \[Object\] -- query object, same as in `useQuery()`.
153
154**Example:**
155
156```js
157export default observer(function NewPlayer ({ gameId }) {
158 // { $sort: { createdAt: -1 }, $limit: 1 }
159 // is added automatically to the query, so the newest player will be returned.
160 // It's also reactive, so whenever a new player joins, you'll receive the new data and model.
161 let [newPlayer, $newPlayer] = useQueryDoc('players', { gameId })
162 if (!newPlayer) return null // <Loading />
163
164 return (
165 <div>New player joined: {newPlayer.name}</div>
166 )
167})
168```
169
170### `useLocal(path)`
171
172Subscribe to the data you already have in your local model by path.
173
174You will usually use it to subscribe to private collections like `_page` or `_session`.
175This is very useful when you want to share the state between multiple components.
176
177It's also possible to subscribe to the path from a public collection, for example when you
178want to work with some nested value of a particular document you have already subscribed to.
179
180**Returns:** `[value, $value]`, where:
181
182`value` \[any\] -- data, located on that `path`
183
184`$value` \[Model\] -- model, targeting that `path`
185
186**Example:**
187
188```js
189const SIDEBAR_OPENED = '_page.Sidebar.opened'
190
191const Topbar = observer(() => {
192 let [sidebarOpened, $sidebarOpened] = useLocal(SIDEBAR_OPENED)
193 return <>
194 <button
195 onClick={() => $sidebarOpened.set(!sidebarOpened)}
196 >Toggle Sidebar</button>
197 </>
198})
199
200const Sidebar = observer(() => {
201 let [sidebarOpened] = useLocal(SIDEBAR_OPENED)
202 return sidebarOpened ? <p>Sidebar</p> : null
203})
204
205const App = observer(() => {
206 return <>
207 <Topbar />
208 <Sidebar />
209 </>
210})
211```
212
213### `useSession(path)`
214
215A convenience method to access the `_session` local collection.
216
217```js
218let [userId, $userId] = useSession('userId')
219// It's the same as doing:
220let [userId, $userId] = useLocal('_session.userId')
221```
222
223### `usePage(path)`
224
225A convenience method to access the `_page` local collection.
226
227**Example:**
228
229```js
230let [game, $game] = usePage('game')
231// It's the same as doing:
232let [game, $game] = useLocal('_page.game')
233```
234
235### `useValue(defaultValue)`
236
237An observable alternative to `useState`.
238
239**Example:**
240
241```js
242const DEFAULT_USER = {
243 first: 'John',
244 last: 'Smith',
245 address: 'Washington St.'
246}
247
248const Field = observer(({ label, $value }) => {
249 return <div>
250 <span>{label}: </span>
251 <input value={$value.get()} onChange={e => $value.set(e.target.value)} />
252 </div>
253})
254
255const User = observer(() => {
256 let [user, $user] = useValue(DEFAULT_USER)
257
258 return <>
259 <Field label='First' $value={$user.at('first')} />
260 <Field label='Last' $value={$user.at('last')} />
261 <Field label='Address' $value={$user.at('address')} />
262 <code>{user}</code>
263 </>
264})
265```
266
267### `useModel(path)`
268
269Return a model scoped to `path` (memoized by the `path` argument).
270If `path` is not provided, returns the model scoped to the root path.
271
272**Example:**
273
274```js
275import React from 'react'
276import {render} from 'react-dom'
277import {observer, useModel, useLocal} from 'startupjs'
278
279const Main = observer(() => {
280 return (
281 <div style={{display: 'flex'}}>
282 <Content />
283 <Sidebar />
284 </div>
285 )
286})
287
288const Content = observer(() => {
289 let $showSidebar = useModel('_page.Sidebar.show')
290
291 // sidebar will be opened without triggering rerendering of the <Content /> component (this component)
292 return (
293 <div>
294 <p>I am Content</p>
295 <button onClick={() => $showSidebar.setDiff(true)}>Open Sidebar</button>
296 </div>
297 )
298})
299
300const Sidebar = observer(() => {
301 let [show, $show] = useLocal('_page.Sidebar.show')
302 if (!show) return null
303 return (
304 <div>
305 <p>I am Sidebar</p>
306 <button onClick={() => $show.del()}>Close</button>
307 </div>
308 )
309})
310
311render(<Main />, document.body.appendChild(document.createElement('div')))
312```
313
314### Hooks Example
315
316```js
317import React from 'react'
318import {observer, useDoc, useQuery, useLocal, useValue} from 'startupjs'
319
320export default observer(function Game ({gameId}) {
321 let [secret, $secret] = useValue('Game Secret Password')
322 let [userId, $userId] = useLocal('_session.userId')
323 let [user, $user] = useDoc('users', userId)
324 let [game, $game] = useDoc('games', gameId)
325 let [players, $players] = useQuery('players', {_id: {$in: game.playerIds}})
326 let [users, $users] = useQuery('users', {_id: {$in: players.map(i => i.userId)}})
327
328 function updateSecret (event) {
329 $secret.set(event.target.value)
330 }
331
332 function updateName (event) {
333 $user.set('name', event.target.value)
334 }
335
336 return (
337 <div>
338 <label>
339 Secret:
340 <input type='text' value={code} onChange={updateSecret} />
341 </label>
342
343 <label>
344 My User Name:
345 <input type='text' value={user.name} onChange={updateName} />
346 </label>
347
348 <h1>Game {game.title}</h1>
349
350 <h2>Users in game:</h2>
351 <p>{users.map(i => i.name).join(', ')}</p>
352 </div>
353 )
354})
355```
356
357## Classes Usage
358
359### `@subscribe(cb)` HOC
360
361`@subscribe` decorator is used to specify what you want to subscribe to.
362
363`@subscribe` gives react component a personal local scope model, located at path `$components.<random_id>`.
364This model will be automatically cleared when the component is unmounted.
365
366### HOW TO: Subscribe to data and use it in render()
367
368`@subscribe` accepts a single argument -- `cb`, which receives `props` and must return the `subscriptions object`.
369
370Each `key` in the `subscriptions object` will fetch specified data from the MongoDB or a Local path and
371will write it into the component's personal scope model under that `key`.
372
373The read-only data of the component's model is available as `this.props.store`.
374Use it to render the data you subscribed to, same way you would use `this.state`.
375
376**IMPORTANT** As with `this.state`, the `this.props.store` **SHOULD NOT** be modified directly! Read below to find out how to modify data.
377
378Example:
379
380```js
381import React from 'react'
382import {subscribe, subLocal, subDoc, subQuery} from 'startupjs'
383
384@subscribe(props => ({
385 myUserId: subLocal('_session.userId'),
386 room: subDoc('rooms', props.roomId)
387}))
388@subscribe(props => ({
389 myUser: subDoc('users', props.store.userId),
390 usersInRoom: subQuery('users', {_id: {
391 $in: props.store.room && props.store.room.userIds || []
392 }})
393}))
394export default class Room extends React.Component {
395 render () {
396 let {room, myUser, usersInRoom} = this.props.store
397 return <Fragment>
398 <h1>{room.name}</h1>
399 <h2>Me: {myUser.name}</h2>
400 <p>Other users in the room: {usersInRoom.map(user => user.name)}</p>
401 </Fragment>
402 }
403}
404```
405
406As seen from the example, you can combine multiple `@subscribe` one after another.
407
408### HOW TO: Modify the data you subscribed to
409
410The actual scoped model of the component is available as `this.props.$store`.
411Use it to modify the data. For the API to modify stuff refer to the [Racer documentation](https://derbyjs.com/docs/derby-0.10/models)
412
413In addition, all things from `subscriptions object` you subscribed to are
414available to you as additional scope models in `this.props` under names `this.props.$KEY`
415
416Example:
417
418```js
419import React from 'react'
420import {subscribe, subLocal, subDoc, subQuery} from 'startupjs'
421
422@subscribe(props => ({
423 room: subDoc('rooms', props.roomId)
424}))
425export default class Room extends React.Component {
426 updateName = () => {
427 let {$store, $room} = this.props
428 $room.set('name', 'New Name')
429 // You can also use $store to do the same:
430 $store.set('room.name', 'New Name')
431 }
432 render () {
433 let {room} = this.props.store
434 return <Fragment>
435 <h1>{room.name}</h1>
436 <button onClick={this.updateName}>Update Name</button>
437 </Fragment>
438 }
439}
440```
441
442## *\[Classes\]* `sub*()` functions
443
444Use sub*() functions to define a particular subscription.
445
446<a name="subDoc"></a>
447### `subDoc(collection, docId)`
448
449Subscribe to a particular document.
450You'll receive the document data as `props.store.{key}`
451
452`collection` \[String\] -- collection name. Required
453`docId` \[String\] -- document id. Required
454
455Example:
456
457```js
458@subscribe(props => ({
459 room: subDoc('rooms', props.roomId)
460}))
461```
462
463<a name="subQuery"></a>
464### `subQuery(collection, query)`
465
466Subscribe to the Mongo query.
467You'll receive the docuents as an array: `props.store.{key}`
468You'll also receive an array of ids of those documents as `props.store.{key}Ids`
469
470`collection` \[String\] -- collection name. Required
471`query` \[Object\] -- query (regular, `$count`, `$aggregate` queries are supported). Required
472
473Example:
474
475```js
476@subscribe(props => ({
477 users: subQuery('users', { roomId: props.roomId, anonymous: false })
478}))
479```
480
481**IMPORTANT:** The scope model `${key}`, which you receive from subscription, targets
482an the array in the local model of the component, NOT the global `collection` path.
483So you won't be able to use it to efficiently update document's data. Instead you should manually
484create a scope model which targets a particular document, using the id:
485
486```js
487let {usersInRoom, $store} = this.props.store
488for (let user of usersInRoom) {
489 $store.scope(`${collection}.${user.id}`).setEach({
490 joinedRoom: true,
491 updatedAt: Date.now()
492 })
493}
494```
495
496<a name="subLocal"></a>
497### `subLocal(path)`
498
499Subscribe to the data you already have in your local model by path.
500You'll receive the data on that path as `props.store.{key}`
501
502You will usually use it to subscribe to private collections like `_page` or `_session`.
503This is very useful when you want to share the state between multiple components.
504
505It's also possible to subscribe to the path from a public collection, for example when you
506want to work with some nested value of a particular document you have already subscribed to.
507
508Example:
509
510```js
511const TopBar = subscribe(props => ({
512 sidebarOpened: subLocal('_page.Sidebar.opened')
513}))({
514 $sidebarOpened
515}) =>
516 <button
517 onClick={() => $sidebarOpened.setDiff(true)}
518 >Open Sidebar</button>
519
520const Sidebar = subscribe(props => ({
521 sidebarOpened: subLocal('_page.Sidebar.opened')
522}))({
523 store: {sidebarOpened}
524}) =>
525 sidebarOpened ? <p>Sidebar</p> : null
526```
527
528<a name="subValue"></a>
529### `subValue(value)`
530
531A constant value to assign to the local scoped model of the component.
532
533`value` \[String\] -- value to assign (any type)
534
535### *\[Classes\]* Example
536
537Below is an example of a simple app with 2 components:
5381. `Home` -- sets up my userId and renders `Room`
5392. `Room` -- shows my user name ands lets user update it,
540 shows all users which are currently in the room.
541
542```js
543// Home.jsx
544import React from 'react'
545import Room from './Room.jsx'
546import {model, subscribe, subLocal, subDoc, subQuery} from 'startupjs'
547
548// `subscribe` means that the data is reactive and gets dynamically updated
549// whenever the data in MongoDB or in private collections gets updated.
550
551@subscribe(props => ({
552 // Subscribe to the path in a private collection `_session`.
553 //
554 // `private` collection means that the data does NOT get synced with MongoDB.
555 // Data in these collections live only on the client-side.
556 //
557 // Collection is considered `private` when its name starts from `_` or `$`.
558 //
559 // Private collections like `_session` are used as a singleton client-only storage,
560 // an alternative to `Redux` or `MobX`.
561 //
562 // You can have as many private collections as you like, but the common
563 // guideline is to use just one collection to store all private data -- `_session`
564 userId: subLocal('_session.userId')
565}))
566
567export default class Home extends React.Component {
568 constructor (...props) {
569 super(...props)
570
571 // For each thing you subscribe to, you receive a scoped `model`
572 // with the same name prefixed with `$` in `props`. Use it
573 // to update the data with the `model` operations available in Racer:
574 // https://derbyjs.com/docs/derby-0.10/models
575 let {$userId} = this.props
576
577 // Update the private path `_session.userId` with the new value
578 //
579 // We'll use this `_session.userId` in all other children
580 // components to easily get the userId without doing the potentially
581 // heavy/long process of fetching the userId over and over again.
582 $userId.set(this.getUserId())
583 }
584
585 // Get my userId somehow (for example from the server-side response).
586 // For simplicity, we'll just generate a random guid in this example
587 // by creating an empty user document each time, so whenever
588 // you open a new page, you'll be a new user.
589 getUserId = () => model.add('users', {})
590
591 render = () => <Room roomId={'myCoolRoom1'} />
592}
593```
594
595```js
596// Room.jsx
597import React from 'react'
598import {model, subscribe, subLocal, subDoc, subQuery} from 'startupjs'
599
600@subscribe(props => ({
601
602 // Subscribe to the same private path again to get the userId, which
603 // we did manually setup in the parent `<Home>` component.
604 // The pattern of subscribing and updating data in a private path
605 // can be used to expose some data from one component to another.
606 userId: subLocal('_session.userId'),
607
608 // Subscribe to the particular document from a public collection `rooms` by id.
609 // `public` means that it DOES sync with MongoDB.
610 // `public` collection names start with a regular letter a-z.
611 // You can also use ClassCase for collection names (A-Z), but it is
612 // NOT recommended unless you have such guidelines in your project.
613 room: subDoc('rooms', props.roomId)
614
615}))
616
617// All things you subscribe to end up in `props.store` under the same name.
618// We can do a second subscribe using the data we received in the first one.
619
620@subscribe(props => ({
621
622 // Subscribe to the particular document from a public collection by id
623 // which we got from the previous subscription
624 myUser: subDoc('users', props.store.userId),
625
626 // Subscribe to a query to get docs from a public `users` collection
627 // using the array of userIds from `room` received in the previous subscription
628 users: subQuery('users', {_id: {
629 $in: props.store.room && props.store.room.userIds || []
630 }})
631
632}))
633
634// Additionally, whenever you subscribe to the MongoDB query, you also
635// receive the `Ids` in store.
636// For example, subscribing to the `users` collection above populated
637// `props.store.users` (array of documents) AND
638// `props.store.userIds` (array of ids) - auto singular name with the `Ids` suffix
639
640export default class Room extends React.Component {
641 constructor (...props) {
642 super(...props)
643 let {$room, roomId} = this.props
644 let {userId, room, room: {userIds = []}} = this.props.store
645
646 // Create an empty room if it wasn't created yet
647 if (!room) model.add('rooms', {id: roomId, title: `Room #${roomId}`})
648
649 // Add user to the room unless he's already there
650 if (!userIds.includes(userId)) $room.push('userIds', userId)
651 }
652
653 changeName = (event) => {
654 let {$myUser} = this.props
655 $myUser.setEach({name: event.target.value})
656 }
657
658 render () {
659 let { myUser, room, users, userId } = this.props.store
660 return (
661 <main>
662 <h1>{room.title}</h1>
663 <section>
664 My User Name:
665 <input type='text' value={myUser.name} onChange={this.changeName} />
666 </section>
667 <h3>Users in the room:</h3>
668 {
669 users
670 .filter(({id}) => id !== userId) // exclude my user
671 .map(user =>
672 <section key={user.id}>
673 {user.name || `User ${user.id}`}
674 </section>
675 )
676 }
677 </main>
678 )
679 }
680}
681```
682
683## Licence
684
685MIT
686
687(c) Decision Mapper - http://decisionmapper.com