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