UNPKG

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