Skip to content

Commit b0133e9

Browse files
authored
UI components coding guidelines (#18926)
* UI components coding guidelines
1 parent 3c4f72c commit b0133e9

File tree

21 files changed

+339
-68
lines changed

21 files changed

+339
-68
lines changed

Diff for: doc/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
[Coding guidelines](new-guidelines.md)
1212

13+
[UI components coding guidelines](ui-guidelines.md)
14+
1315
[Release Checklist](release-checklist.md)
1416

1517
[Release Guide](release-guide.md)

Diff for: doc/react_tree.png

41.2 KB
Loading

Diff for: doc/ui-guidelines.md

+254
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# UI components coding guidelines
2+
3+
> [!IMPORTANT]
4+
> React apps are made out of components. A component is a piece of the UI (user interface) that has its own logic and appearance. A component can be as small as a button, or as large as an entire screen.
5+
> React components are JavaScript functions that return markup
6+
> This document will provide best practices on how to write efficient React components in ClojureScript
7+
8+
9+
At the time of creating the Status app, the Reagent library was a solid choice. Back then, hooks didn't exist, and there weren't any libraries providing effective global state management. After Reagent's emergence, another library called Re-frame built upon Reagent. Together, they offered powerful tools for developing React applications with ClojureScript. However, as React evolved, significant changes occurred. Class components, utilized in Reagent, became deprecated. Instead, functional components and hooks emerged for state management. In Status 2.0, we began incorporating more functional components and hooks, resulting in a blend of both approaches. To simplify matters and reduce confusion, we opted to transition to functional components and hooks for local state management.
10+
11+
BEFORE:
12+
```clojure
13+
(defn- view-internal
14+
[_ _]
15+
(let [pressed? (reagent/atom false)]
16+
(fn
17+
[{:keys [theme on-press on-long-press icon]}]
18+
[rn/pressable
19+
{:style (style/main @pressed? theme)
20+
:on-press on-press
21+
:on-press-in #(reset! pressed? true)
22+
:on-press-out #(reset! pressed? nil)
23+
:on-long-press on-long-press}
24+
[quo.icons/icon icon]])))
25+
26+
(def view (theme/with-theme view-internal))
27+
```
28+
29+
NOW:
30+
```clojure
31+
(defn view
32+
[{:keys [on-press on-long-press icon]}]
33+
(let [[pressed? set-pressed] (rn/use-state false)
34+
theme (theme/use-theme-value)
35+
on-press-in (rn/use-callback #(set-pressed true))
36+
on-press-out (rn/use-callback #(set-pressed nil))]
37+
[rn/pressable
38+
{:style (style/main pressed? theme)
39+
:on-press on-press
40+
:on-press-in on-press-in
41+
:on-press-out on-press-out
42+
:on-long-press on-long-press}
43+
[quo.icons/icon icon]]))
44+
```
45+
46+
47+
- We no longer need to create an anonymous function for rendering. This removes unnecessary confusion and the need for specific knowledge on how it works and why it was needed.
48+
- `rn/use-state` is used instead of `reagent/atom`
49+
- State values no longer need to be dereferenced; they are accessible as regular symbols. This eliminates a common bug where the "@" symbol was inadvertently omitted.
50+
- `theme/with-theme` wrapper is not needed anymore, `(theme/use-theme-value)` hook can be used directly in the components
51+
- `:f>` not needed anymore, all components are functional by default
52+
- `rn/use-callback` hook should be used for anon callback functions
53+
54+
> [!IMPORTANT]
55+
> DO NOT USE anon functions directly in the props
56+
57+
BAD
58+
```clojure
59+
(defn view
60+
[]
61+
(let [[pressed? set-pressed] (rn/use-state false)]
62+
[rn/pressable
63+
{:style (style/main pressed?)
64+
:on-press-in #(set-pressed true)
65+
:on-press-out #(set-pressed nil)}]))
66+
```
67+
68+
GOOD:
69+
```clojure
70+
(defn view
71+
[]
72+
(let [[pressed? set-pressed] (rn/use-state false)
73+
on-press-in (rn/use-callback #(set-pressed true))
74+
on-press-out (rn/use-callback #(set-pressed nil))]
75+
[rn/pressable
76+
{:style (style/main pressed?)
77+
:on-press-in on-press-in
78+
:on-press-out on-press-out}]))
79+
```
80+
81+
## Global state and subscriptions
82+
83+
For global state management, we utilize Re-frame subscriptions. They can be likened to React state. To obtain the state, `(rf/sub [])` is employed, and to modify it, `(rf/dispatch [])` is utilized. However, they update components in a similar manner to React states.
84+
85+
```clojure
86+
(defn view
87+
[{:keys [selected-tab]}]
88+
(let [collectible-list (rf/sub [:wallet/all-collectibles])
89+
on-collectible-press (rn/use-callback
90+
(fn [{:keys [id]}]
91+
(rf/dispatch [:wallet/get-collectible-details id])))]
92+
[rn/view {:style style/container}
93+
(case selected-tab
94+
:assets [assets/view]
95+
:collectibles [collectibles/view {:collectibles collectible-list
96+
:on-collectible-press on-collectible-press}])
97+
[activity/view]]))
98+
```
99+
100+
## Regular atoms
101+
102+
In certain instances, components utilized regular atoms; however, they should now be used with `rn/use-ref-atom`
103+
104+
BEFORE:
105+
```clojure
106+
(defn view
107+
[]
108+
(let [focused? (atom false)]
109+
(fn []
110+
(let [on-clear #(reset! status (if @focused? :active :default))
111+
on-focus #(reset! focused? true)
112+
on-blur #(reset! focused? false)]))))
113+
```
114+
115+
NOW:
116+
```clojure
117+
(defn view
118+
[]
119+
(let [focused? (rn/use-ref-atom false)
120+
on-clear (rn/use-callback #(set-status (if @focused? :active :default)))
121+
on-focus (rn/use-callback #(reset! focused? true))
122+
on-blur (rn/use-callback #(reset! focused? false))]))
123+
```
124+
125+
## Effects
126+
127+
LIFECYCLE:
128+
129+
```clojure
130+
(defn view
131+
[{:keys []}]
132+
(let [opacity (reanimated/use-shared-value 0)]
133+
(rn/use-mount #(reanimated/animate opacity 1))
134+
[rn/view
135+
{:style (style/opacity opacity)}]))
136+
```
137+
138+
```clojure
139+
(defn view
140+
[{:keys []}]
141+
(let []
142+
(rn/use-unmount #(rn/dispatch [:unmounted]))
143+
[rn/view]))
144+
```
145+
146+
> [!IMPORTANT]
147+
> Effects should NOT be utilized as a response to state changes for modifying logic. If you're unsure how to achieve this without using effects, please consult the team in the general chat. There may be instances where using effects is appropriate, so we can explore a solution together and enhance our guidelines.
148+
149+
BAD:
150+
```clojure
151+
(defn f-zoom-button
152+
[{:keys [selected? current-zoom]}]
153+
(let [size (reanimated/use-shared-value (if selected? 37 25))]
154+
(rn/use-effect #(reanimated/animate size (if selected? 37 25)) [current-zoom])
155+
[rn/touchable-opacity
156+
{:style (style/zoom-button-container size)}]))
157+
```
158+
159+
BAD:
160+
161+
```clojure
162+
(defn view
163+
[collectible-list (rf/sub [:wallet/all-collectibles])]
164+
(let []
165+
(rn/use-effect #(rn/dispatch [:all-collectibles-changed]) [collectible-list])
166+
[rn/view]))
167+
```
168+
169+
Instead `:all-collectibles-changed` should be used in the handler which changes `collectible-list` state
170+
171+
172+
173+
## Performance tips
174+
175+
To begin with, we need to understand that there are two distinct stages for a component: creation and update. React creates a render tree, a UI tree, composed of the rendered components.
176+
177+
![react_tree.png](react_tree.png)
178+
179+
### Component creation
180+
181+
For component creation, the most critical factor is the number of elements involved, so we should strive to minimize them. For instance, it's advisable to avoid using unnecessary wrappers or containers.
182+
183+
BAD:
184+
185+
```clojure
186+
(defn view
187+
[]
188+
(let []
189+
[rn/view {:style {:padding-top 20}}
190+
[quo/button]]))
191+
```
192+
193+
GOOD:
194+
```clojure
195+
(defn view
196+
[]
197+
(let []
198+
[quo/button {:container-style {:padding-top 20}}]))
199+
```
200+
201+
### Component updates
202+
203+
For component updates, it's crucial to recognize that React will invoke the function where state is utilized. Therefore, if you utilize state in the root component, React will execute the root function and re-render the entire root component along with all its children (unless optimizations like memoization are implemented).
204+
205+
BAD:
206+
207+
```clojure
208+
(defn component
209+
[{:keys [label]}]
210+
(let []
211+
[rn/text label]))
212+
213+
(defn component2
214+
[{:keys [label2]}]
215+
(let []
216+
[rn/text label2]))
217+
218+
(defn screen
219+
[]
220+
(let [screen-params (rf/sub [:screen-params])]
221+
[component screen-params]
222+
[component1]
223+
[component2 screen-params]
224+
[component3]
225+
[rn/view {:padding-top 20}
226+
[quo/button]]))
227+
```
228+
229+
Here, we have lost control over the `screen-params` map. It can contain any data, and if any field within this map changes, the entire screen function will be executed, resulting in the re-rendering of both `component` and `component2`.
230+
231+
GOOD:
232+
```clojure
233+
(defn component
234+
[]
235+
(let [label (rf/sub [:screen-params-label])]
236+
[rn/text label]))
237+
238+
(defn component2
239+
[]
240+
(let [label2 (rf/sub [:screen-params-label2])]
241+
[rn/text label2]))
242+
243+
(defn screen
244+
[]
245+
(let []
246+
[component]
247+
[component1]
248+
[component2]
249+
[component3]
250+
[rn/view {:padding-top 20}
251+
[quo/button]]))
252+
```
253+
254+
So, now the screen component function will never be invoked, and `component` and `component2` will be re-rendered only when `label` or `label2` have changed.

Diff for: src/legacy/status_im/bottom_sheet/sheets.cljs

+4-4
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@
2828

2929
[:f>
3030
(fn []
31-
(rn/use-effect (fn []
32-
(rn/hw-back-add-listener dismiss-bottom-sheet-callback)
33-
(fn []
34-
(rn/hw-back-remove-listener dismiss-bottom-sheet-callback))))
31+
(rn/use-mount (fn []
32+
(rn/hw-back-add-listener dismiss-bottom-sheet-callback)
33+
(fn []
34+
(rn/hw-back-remove-listener dismiss-bottom-sheet-callback))))
3535
[theme/provider {:theme (or page-theme (theme/get-theme))}
3636
[bottom-sheet/bottom-sheet opts
3737
(when content

Diff for: src/quo/components/colors/color_picker/view.cljs

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
(let [selected (reagent/atom default-selected)
2828
{window-width :width} (rn/get-window)
2929
ref (atom nil)]
30-
(rn/use-effect
30+
(rn/use-mount
3131
(fn []
3232
(js/setTimeout
3333
(fn []

Diff for: src/quo/components/drawers/drawer_buttons/view.cljs

+3-3
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,9 @@
177177
1
178178
animations-duration
179179
:easing4))]
180-
(rn/use-effect (fn []
181-
(when on-init
182-
(on-init reset-top-animation))))
180+
(rn/use-mount (fn []
181+
(when on-init
182+
(on-init reset-top-animation))))
183183
[reanimated/view {:style (style/outer-container height border-radius container-style)}
184184
[blur/view
185185
{:blur-type :dark

Diff for: src/quo/components/record_audio/record_audio/view.cljs

+15-15
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
[quo.foundations.colors :as colors]
1616
[quo.theme :as quo.theme]
1717
[react-native.audio-toolkit :as audio]
18-
[react-native.core :as rn :refer [use-effect]]
18+
[react-native.core :as rn]
1919
[react-native.platform :as platform]
2020
[reagent.core :as reagent]
2121
[taoensso.timbre :as log]
@@ -528,20 +528,20 @@
528528
(reset! reached-max-duration? false))
529529
(reset! touch-timestamp nil))]
530530
(fn []
531-
(use-effect (fn []
532-
(when on-check-audio-permissions
533-
(on-check-audio-permissions))
534-
(when on-init
535-
(on-init reset-recorder))
536-
(when audio-file
537-
(let [filename (last (string/split audio-file "/"))]
538-
(reload-player filename)))
539-
(reset! app-state-listener
540-
(.addEventListener rn/app-state
541-
"change"
542-
#(when (= % "background")
543-
(reset! playing-audio? false))))
544-
#(.remove @app-state-listener)))
531+
(rn/use-mount (fn []
532+
(when on-check-audio-permissions
533+
(on-check-audio-permissions))
534+
(when on-init
535+
(on-init reset-recorder))
536+
(when audio-file
537+
(let [filename (last (string/split audio-file "/"))]
538+
(reload-player filename)))
539+
(reset! app-state-listener
540+
(.addEventListener rn/app-state
541+
"change"
542+
#(when (= % "background")
543+
(reset! playing-audio? false))))
544+
#(.remove @app-state-listener)))
545545
[rn/view
546546
{:style style/bar-container
547547
:pointer-events :box-none}

0 commit comments

Comments
 (0)