@@ -0,0 +1,406 @@
1++++
2+title = "8. React app logic"
3+weight = 8
4+template = "doc.html"
5++++
6+
7+With the basic things setup, we can now go over the logic of our app. We'll just
8+focus on functions that are related to ship communications using the `Urbit`
9+object we previously setup, and ignore UI components and other helper functions.
10+
11+## State
12+
13+In the previous section we just mentioned the connection `status` field of our
14+state. Here's the full state of our App:
15+
16+```js
17+state = {
18+ entries: [], // list of journal entries for display
19+ drafts: {}, // edits which haven't been submitted yet
20+ newDraft: {}, // new entry which hasn't been submitted yet
21+ results: [], // search results
22+ searchStart: null, // search query start date
23+ searchEnd: null, // search query end date
24+ resultStart: null, // search results start date
25+ resultEnd: null, // search results end date
26+ searchTime: null, // time of last search
27+ latestUpdate: null, // most recent update we've received
28+ entryToDelete: null, // deletion target for confirmation modal
29+ status: null, // connection status (con, try, err)
30+ errorCount: 0, // number of errors so far
31+ errors: new Map(), // list of error messages for display
32+};
33+```
34+
35+We'll see how these are used subsequently.
36+
37+## Initialize
38+
39+The first thing our app does is call `init()`:
40+
41+```js
42+init = () => {
43+ this.getEntries().then(
44+ (result) => {
45+ this.handleUpdate(result);
46+ this.setState({ latestUpdate: result.time });
47+ this.subscribe();
48+ },
49+ (err) => {
50+ this.setErrorMsg("Connection failed");
51+ this.setState({ status: "err" });
52+ }
53+ );
54+};
55+```
56+
57+This function just calls `getEntries()` to retrieve the initial list of journal
58+entries then, if that succeeded, it calls `subscribe()` to subscribe for new
59+updates. If the initial entry retrieval failed, we set the connection `status`
60+and save an error message in the `errors` map. We'll look at what we do with
61+errors later.
62+
63+## Getting entries
64+
65+
66+
67+The `getEntries` function scries our `%journal` agent for up to 10 entries
68+before the oldest we currently have. We call this initially, and then each time
69+the user scrolls to the bottom of the list.
70+
71+```js
72+getEntries = async () => {
73+ const { entries: e } = this.state;
74+ const before = e.length === 0 ? Date.now() : e[e.length - 1].id;
75+ const max = 10;
76+ const path = `/entries/before/${before}/${max}`;
77+ return window.urbit.scry({
78+ app: "journal",
79+ path: path,
80+ });
81+};
82+```
83+
84+The scry is done with the `Urbit.scry` method. This function takes two arguments
85+in an object:
86+
87+- `app` - the agent to scry.
88+- `path` - the scry path. Note the `care` is not included - all scries through
89+ Eyre are `%x` scries.
90+
91+The `Urbit.scry` method only allows JSON results, but note that scries done via
92+direct GET requests allow other marks too.
93+
94+The `Urbit.scry` method returns a Promise which will contain an HTTP error
95+message if the scry failed. We handle it with a `.then` expression back in the
96+function that called it, either [`init()`](#initialize) or `moreEntries()`. If
97+the Promise is successfuly, the results are passed to the
98+[`handleUpdate`](#updates) function which appends the new entries to the
99+existing ones in state.
100+
101+## Subscription
102+
103+A subscription to the `/updates` path of our `%journal` agent is opened with our
104+`subscribe()` function:
105+
106+```js
107+subscribe = () => {
108+ try {
109+ window.urbit.subscribe({
110+ app: "journal",
111+ path: "/updates",
112+ event: this.handleUpdate,
113+ err: () => this.setErrorMsg("Subscription rejected"),
114+ quit: () => this.setErrorMsg("Kicked from subscription"),
115+ });
116+ } catch {
117+ this.setErrorMsg("Subscription failed");
118+ }
119+};
120+```
121+
122+We use the `Urbit.subscribe` method for this, which takes five arguments in an
123+object:
124+
125+- `app` - the target agent.
126+- `path` - the `%watch` path we're subscribing to.
127+- `event` - a function to handle each fact the agent sends out. We call our
128+ `handleUpdate` function, which we'll describe below.
129+- `err` - a function to call if the subscription request is rejected (nacked).
130+ We just display an error in this case.
131+- `quit` - a function to call if we get kicked from the subscription. We also
132+ just display an error in this case.
133+
134+Note that the `Urbit.subscribe` method returns a subscription ID number. Since
135+we only have one subscription in our app which we never close, we don't bother
136+to record it. If your app has multiple subscriptions to manage, you may wish to
137+keep track of these IDs in your app's state.
138+
139+## Updates
140+
141+This `handleUpdate` function handles all updates we receive. It's called
142+whenever an event comes in for our subscription, and it's also called with the
143+results of [`getEntries`](#getting-entries) and [`getUpdates`](#error-handling)
144+(described later).
145+
146+It's a bit complex, but basically it just checks whether the JSON object is
147+`add`, `edit`, `delete`, or `entries`, and then updates the state appropriately.
148+The object it's receiving is just the `$update` structure converted to JSON by
149+the mark conversion functions we wrote previously.
150+
151+```js
152+handleUpdate = (upd) => {
153+ const { entries, drafts, results, latestUpdate } = this.state;
154+ if (upd.time !== latestUpdate) {
155+ if ("entries" in upd) {
156+ this.setState({ entries: entries.concat(upd.entries) });
157+ } else if ("add" in upd) {
158+ const { time, add } = upd;
159+ const eInd = this.spot(add.id, entries);
160+ const rInd = this.spot(add.id, results);
161+ const toE =
162+ entries.length === 0 || add.id > entries[entries.length - 1].id;
163+ const toR = this.inSearch(add.id, time);
164+ toE && entries.splice(eInd, 0, add);
165+ toR && results.splice(rInd, 0, add);
166+ this.setState({
167+ ...(toE && { entries: entries }),
168+ ...(toR && { results: results }),
169+ latestUpdate: time,
170+ });
171+ } else if ("edit" in upd) {
172+ const { time, edit } = upd;
173+ const eInd = entries.findIndex((e) => e.id === edit.id);
174+ const rInd = results.findIndex((e) => e.id === edit.id);
175+ const toE = eInd !== -1;
176+ const toR = rInd !== -1 && this.inSearch(edit.id, time);
177+ if (toE) entries[eInd] = edit;
178+ if (toR) results[rInd] = edit;
179+ (toE || toR) && delete drafts[edit.id];
180+ this.setState({
181+ ...(toE && { entries: entries }),
182+ ...(toR && { results: results }),
183+ ...((toE || toR) && { drafts: drafts }),
184+ latestUpdate: time,
185+ });
186+ } else if ("del" in upd) {
187+ const { time, del } = upd;
188+ const eInd = entries.findIndex((e) => e.id === del.id);
189+ const rInd = results.findIndex((e) => e.id === del.id);
190+ const toE = eInd !== -1;
191+ const toR = this.inSearch(del.id, time) && rInd !== -1;
192+ toE && entries.splice(eInd, 1);
193+ toR && results.splice(rInd, 1);
194+ (toE || toR) && delete drafts[del.id];
195+ this.setState({
196+ ...(toE && { entries: entries }),
197+ ...(toR && { results: results }),
198+ ...((toE || toR) && { drafts: drafts }),
199+ latestUpdate: time,
200+ });
201+ }
202+ }
203+};
204+```
205+
206+## Add, edit, delete
207+
208+
209+
210+When a user writes a new journal entry and hits submit, the `submitNew` function
211+is called. It uses the `Urbit.poke` method to poke our `%journal` agent.
212+
213+```js
214+submitNew = (id, txt) => {
215+ window.urbit.poke({
216+ app: "journal",
217+ mark: "journal-action",
218+ json: { add: { id: id, txt: txt } },
219+ onSuccess: () => this.setState({ newDraft: {} }),
220+ onError: () => this.setErrorMsg("New entry rejected"),
221+ });
222+};
223+```
224+
225+The `Urbit.poke` method takes five arguments:
226+
227+- `app` is the agent to poke.
228+- `mark` is the mark of the data we're sending. We specify `"journal-action"`,
229+ so Eyre will use the `/mar/journal/action.hoon` mark we created to convert it
230+ to a `$action` structure with a `%journal-action` mark before it's delivered
231+ to our agent.
232+- `json` is the actual data we're poking our agent with. In this case it's the
233+ JSON form of the `%add` `$action`.
234+- `onSuccess` is a callback that fires if we get a positive ack in response. In
235+ this case we just clear the draft.
236+- `onError` is a callback that fires if we get a negative ack (nack) in
237+ response, meaning the poke failed. In this case we just set an error message
238+ to be displayed.
239+
240+`onSuccess` and `onError` are optional, but it's usually desirable to handle
241+these cases.
242+
243+The `delete` and `submitEdit` functions are similar to `submitNew`, but for the
244+`%del` and `%edit` actions rather than `%add`:
245+
246+
247+
248+```js
249+submitEdit = (id, txt) => {
250+ if (txt !== null) {
251+ window.urbit.poke({
252+ app: "journal",
253+ mark: "journal-action",
254+ json: { edit: { id: id, txt: txt } },
255+ onError: () => this.setErrorMsg("Edit rejected"),
256+ });
257+ } else this.cancelEdit(id);
258+};
259+```
260+
261+
262+
263+```js
264+delete = (id) => {
265+ window.urbit.poke({
266+ app: "journal",
267+ mark: "journal-action",
268+ json: {"del": {"id": id}},
269+ onError: ()=>this.setErrorMsg("Deletion rejected")
270+ })
271+ this.setState({rmModalShow: false, entryToDelete: null})
272+};
273+```
274+
275+Note that whether we're adding, editing or deleting entries, we update our state
276+when we receive the update back on the `/updates` subscription, not when we poke
277+our agent.
278+
279+## Search
280+
281+
282+
283+When searching for entries between two dates, the `getSearch` function is
284+called, which uses the `Urbit.scry` method to scry for the results in a similar
285+fashion to [`getEntries`](#getting-entries), but using the
286+`/x/entries/between/[start]/[end]` endpoint.
287+
288+```js
289+getSearch = async () => {
290+ const { searchStart: ss, searchEnd: se, latestUpdate: lu } = this.state;
291+ if (lu !== null && ss !== null && se !== null) {
292+ let start = ss.getTime();
293+ let end = se.getTime();
294+ if (start < 0) start = 0;
295+ if (end < 0) end = 0;
296+ const path = `/entries/between/${start}/${end}`;
297+ window.urbit
298+ .scry({
299+ app: "journal",
300+ path: path,
301+ })
302+ .then(
303+ (result) => {
304+ this.setState({
305+ searchTime: result.time,
306+ searchStart: null,
307+ searchEnd: null,
308+ resultStart: ss,
309+ resultEnd: se,
310+ results: result.entries,
311+ });
312+ },
313+ (err) => {
314+ this.setErrorMsg("Search failed");
315+ }
316+ );
317+ } else {
318+ lu !== null && this.setErrorMsg("Searh failed");
319+ }
320+};
321+```
322+
323+## Error handling
324+
325+When the channel connection is interrupted, the `Urbit` object will begin trying to reconnect. On each attempt, it sets the connection `status` to `"try"`, as we specified for the `onRetry` callback. When this is set, a "reconnecting" message is displayed at the bottom of the screen:
326+
327+
328+
329+If all three reconnection attempts fail, the `onError` callback is fired and we replace the "reconnecting" message with a "reconnect" button:
330+
331+
332+
333+When clicked, the following function is called:
334+
335+```js
336+reconnect = () => {
337+ window.urbit.reset();
338+ const latest = this.state.latestUpdate;
339+ if (latest === null) {
340+ this.init();
341+ } else {
342+ this.getUpdates().then(
343+ (result) => {
344+ result.logs.map((e) => this.handleUpdate(e));
345+ this.subscribe();
346+ },
347+ (err) => {
348+ this.setErrorMsg("Connection failed");
349+ this.setState({ status: "err" });
350+ }
351+ );
352+ }
353+};
354+```
355+
356+Our `reconnect()` function first calls the `Urbit.reset` method. This closes the
357+channel connection, wipes event counts and subscriptions, and generates a new
358+channel ID. We could have tried reconnecting without resetting the connection,
359+but we don't know whether the channel still exists. We could time how long the
360+connection has been down and estimate whether it still exists, but it's easier
361+to just start fresh in this case.
362+
363+Since we've reset the channel, we don't know if we've missed any updates. Rather
364+than having to refresh our whole state, we can use the `getUpdates()` function
365+to get any missing update:
366+
367+```js
368+getUpdates = async () => {
369+ const { latestUpdate: latest } = this.state;
370+ const since = latest === null ? Date.now() : latest;
371+ const path = `/updates/since/${since}`;
372+ return window.urbit.scry({
373+ app: "journal",
374+ path: path,
375+ });
376+};
377+```
378+
379+This function uses the `Urbit.scry` method to scry the
380+`/x/updates/since/[since]` path, querying the update `$log` for entries more
381+recent than `latestUpdate`, which is always set to the last logged action we
382+received. The `getUpdates` function returns a Promise to the `reconnect`
383+function above which called it. The `reconnect` function handles it in a `.then`
384+expression, where the success case passes each update retrieved to the
385+[`handleUpdate`](#updates) function, updating our state.
386+
387+Lastly, as well as handling channel connection errors, we also handle errors
388+such as poke nacks or failed scries by printing error messages added to the
389+`error` map by the `setErrorMsg` function. You could of course handle nacks,
390+kicks, scry failures, etc differently than just printing an error, it depends on
391+the needs of your app.
392+
393+
394+
395+## Resources
396+
397+- [HTTP API Guide](/docs/userspace/http-api-guide) - Reference documentation for
398+ `@urbit/http-api`.
399+
400+- [React app source
401+ code](https://github.com/urbit/docs-examples/tree/main/journal-app/ui) - The
402+ source code for the Journal app UI.
403+
404+- [`@urbit/http-api` source
405+ code](https://github.com/urbit/urbit/tree/master/pkg/npm/http-api) - The
406+ source code for the `@urbit/http-api` NPM package.