funicular 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- metadata +119 -8
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
# Data Fetching
|
|
2
|
+
|
|
3
|
+
Funicular provides multiple layers for data fetching: low-level HTTP client, high-level Model abstraction (Object-REST Mapper), and Suspense for loading states.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [HTTP Client](#http-client)
|
|
8
|
+
- [Object-REST Mapper (Model)](#object-rest-mapper-model)
|
|
9
|
+
- [Suspense / Loading States](#suspense--loading-states)
|
|
10
|
+
- [Best Practices](#best-practices)
|
|
11
|
+
|
|
12
|
+
## HTTP Client
|
|
13
|
+
|
|
14
|
+
The `Funicular::HTTP` module provides a low-level interface for making HTTP requests.
|
|
15
|
+
|
|
16
|
+
### GET Requests
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
Funicular::HTTP.get('/api/users') do |response|
|
|
20
|
+
if response.ok
|
|
21
|
+
patch(users: response.data)
|
|
22
|
+
else
|
|
23
|
+
patch(error: response.error_message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### POST Requests
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Funicular::HTTP.post('/api/users', { name: "Alice", email: "alice@example.com" }) do |response|
|
|
32
|
+
if response.ok
|
|
33
|
+
patch(user: response.data)
|
|
34
|
+
else
|
|
35
|
+
patch(errors: response.data["errors"])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Other HTTP Methods
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# PATCH
|
|
44
|
+
Funicular::HTTP.patch('/api/users/123', { name: "Alice Updated" }) do |response|
|
|
45
|
+
# ...
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# PUT
|
|
49
|
+
Funicular::HTTP.put('/api/users/123', user_data) do |response|
|
|
50
|
+
# ...
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# DELETE
|
|
54
|
+
Funicular::HTTP.delete('/api/users/123') do |response|
|
|
55
|
+
# ...
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Response Object
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Funicular::HTTP.get('/api/data') do |response|
|
|
63
|
+
response.status # HTTP status code (200, 404, etc.)
|
|
64
|
+
response.ok # true if status is 2xx
|
|
65
|
+
response.data # Parsed JSON response
|
|
66
|
+
response.error? # true if response contains error
|
|
67
|
+
response.error_message # Error message from response
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### CSRF Token
|
|
72
|
+
|
|
73
|
+
For non-GET requests to Rails backends, CSRF tokens are automatically included:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# Funicular automatically:
|
|
77
|
+
# 1. Reads <meta name="csrf-token"> from page
|
|
78
|
+
# 2. Adds X-CSRF-Token header to POST/PATCH/PUT/DELETE requests
|
|
79
|
+
Funicular::HTTP.post('/api/posts', post_data) do |response|
|
|
80
|
+
# CSRF token automatically included
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Object-REST Mapper (Model)
|
|
85
|
+
|
|
86
|
+
The Model layer provides an ActiveRecord-style interface for working with REST APIs.
|
|
87
|
+
|
|
88
|
+
### Defining Models
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
class User < Funicular::Model
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Post < Funicular::Model
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class Channel < Funicular::Model
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Schema Loading
|
|
102
|
+
|
|
103
|
+
Models load their attributes from a schema endpoint:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# In your initializer
|
|
107
|
+
Funicular.load_schemas({
|
|
108
|
+
User => "user",
|
|
109
|
+
Post => "post",
|
|
110
|
+
Channel => "channel"
|
|
111
|
+
}) do
|
|
112
|
+
# Callback executed after all schemas load
|
|
113
|
+
Funicular.start(container: 'app') do |router|
|
|
114
|
+
# Define routes...
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The schema endpoint should return attribute definitions:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# GET /api/schema/user
|
|
123
|
+
{
|
|
124
|
+
"attributes": ["id", "username", "email", "created_at"]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Finding Records
|
|
129
|
+
|
|
130
|
+
#### Find All
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
User.all do |users, error|
|
|
134
|
+
if error
|
|
135
|
+
patch(error: error)
|
|
136
|
+
else
|
|
137
|
+
patch(users: users)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### Find by ID
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
User.find(123) do |user, error|
|
|
146
|
+
if error
|
|
147
|
+
patch(error: "User not found")
|
|
148
|
+
else
|
|
149
|
+
patch(user: user)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### Find with Parameters
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
Post.all({ category: "tech", limit: 10 }) do |posts, error|
|
|
158
|
+
patch(posts: posts)
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Creating Records
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
User.create({
|
|
166
|
+
username: "alice",
|
|
167
|
+
email: "alice@example.com",
|
|
168
|
+
password: "secret"
|
|
169
|
+
}) do |user, errors|
|
|
170
|
+
if errors
|
|
171
|
+
# errors = { email: "Email is already taken" }
|
|
172
|
+
patch(errors: errors)
|
|
173
|
+
else
|
|
174
|
+
patch(user: user, success: "User created!")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Updating Records
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
user = state.user
|
|
183
|
+
user.update({ email: "newemail@example.com" }) do |updated_user, errors|
|
|
184
|
+
if errors
|
|
185
|
+
patch(errors: errors)
|
|
186
|
+
else
|
|
187
|
+
patch(user: updated_user, success: "Profile updated!")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Deleting Records
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
user = state.user
|
|
196
|
+
user.destroy do |response, error|
|
|
197
|
+
if error
|
|
198
|
+
patch(error: "Failed to delete user")
|
|
199
|
+
else
|
|
200
|
+
patch(user: nil, success: "User deleted")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Custom Endpoints
|
|
206
|
+
|
|
207
|
+
For custom API endpoints, use the `endpoint` class method:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class User < Funicular::Model
|
|
211
|
+
def self.endpoint
|
|
212
|
+
{
|
|
213
|
+
"path" => "/api/v2/users",
|
|
214
|
+
"find" => "/api/v2/users/:id",
|
|
215
|
+
"create" => "/api/v2/users",
|
|
216
|
+
"update" => "/api/v2/users/:id",
|
|
217
|
+
"destroy" => "/api/v2/users/:id"
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Accessing Attributes
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
User.find(123) do |user, error|
|
|
227
|
+
user.id # => 123
|
|
228
|
+
user.username # => "alice"
|
|
229
|
+
user.email # => "alice@example.com"
|
|
230
|
+
user.created_at # => "2024-01-15T10:30:00Z"
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Suspense / Loading States
|
|
235
|
+
|
|
236
|
+
Funicular provides a Suspense pattern for handling asynchronous data fetching with declarative loading states.
|
|
237
|
+
|
|
238
|
+
### Basic Usage
|
|
239
|
+
|
|
240
|
+
Declare async data loaders at the class level using `use_suspense`:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class UserProfile < Funicular::Component
|
|
244
|
+
use_suspense :user,
|
|
245
|
+
->(resolve, reject) {
|
|
246
|
+
User.find(props[:id]) do |user, error|
|
|
247
|
+
error ? reject.call(error) : resolve.call(user)
|
|
248
|
+
end
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def render
|
|
252
|
+
div do
|
|
253
|
+
h1 { "Profile" }
|
|
254
|
+
|
|
255
|
+
suspense(
|
|
256
|
+
fallback: -> { div(class: "spinner") { "Loading..." } }
|
|
257
|
+
) do
|
|
258
|
+
div { "Name: #{user.name}" }
|
|
259
|
+
div { "Email: #{user.email}" }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The suspense data (`user`) is automatically accessible as a method within the component.
|
|
267
|
+
|
|
268
|
+
### With Error Handling
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
suspense(
|
|
272
|
+
fallback: -> { div(class: "spinner") { "Loading user..." } },
|
|
273
|
+
error: ->(e) do
|
|
274
|
+
div(class: "error") do
|
|
275
|
+
span { "Failed to load: #{e}" }
|
|
276
|
+
button(onclick: -> { reload_suspense(:user) }) { "Retry" }
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
) do
|
|
280
|
+
div { "Welcome, #{user.name}" }
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Syncing with Form State
|
|
285
|
+
|
|
286
|
+
When using suspense data with forms, use `on_resolve` to sync loaded data:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
class SettingsComponent < Funicular::Component
|
|
290
|
+
use_suspense :current_user,
|
|
291
|
+
->(resolve, reject) {
|
|
292
|
+
Session.current_user do |user, error|
|
|
293
|
+
error ? reject.call(error) : resolve.call(user)
|
|
294
|
+
end
|
|
295
|
+
},
|
|
296
|
+
on_resolve: ->(user) {
|
|
297
|
+
# Sync form state when data loads
|
|
298
|
+
patch(
|
|
299
|
+
form: {
|
|
300
|
+
username: user.username,
|
|
301
|
+
display_name: user.display_name,
|
|
302
|
+
bio: user.bio
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def initialize_state
|
|
308
|
+
{ form: { username: "", display_name: "", bio: "" } }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def render
|
|
312
|
+
suspense(fallback: -> { div { "Loading settings..." } }) do
|
|
313
|
+
form_for(:form, on_submit: :handle_save) do |f|
|
|
314
|
+
f.text_field(:username, disabled: true)
|
|
315
|
+
f.text_field(:display_name)
|
|
316
|
+
f.textarea(:bio)
|
|
317
|
+
f.submit("Save")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Preventing Flickering
|
|
325
|
+
|
|
326
|
+
For fast-loading data, use `min_delay` to ensure the loading state is visible for a minimum duration:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
use_suspense :data,
|
|
330
|
+
->(resolve, reject) {
|
|
331
|
+
API.fetch_data do |data, error|
|
|
332
|
+
error ? reject.call(error) : resolve.call(data)
|
|
333
|
+
end
|
|
334
|
+
},
|
|
335
|
+
min_delay: 300 # Show loading spinner for at least 300ms
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
This prevents UI flickering when data loads very quickly.
|
|
339
|
+
|
|
340
|
+
### Multiple Suspense Sources
|
|
341
|
+
|
|
342
|
+
Declare multiple suspense data sources. The `suspense` helper waits for all of them:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
class Dashboard < Funicular::Component
|
|
346
|
+
use_suspense :user, ->(resolve, reject) {
|
|
347
|
+
User.current do |user, error|
|
|
348
|
+
error ? reject.call(error) : resolve.call(user)
|
|
349
|
+
end
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
use_suspense :stats, ->(resolve, reject) {
|
|
353
|
+
Stats.fetch do |stats, error|
|
|
354
|
+
error ? reject.call(error) : resolve.call(stats)
|
|
355
|
+
end
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
use_suspense :notifications, ->(resolve, reject) {
|
|
359
|
+
Notification.recent do |notifications, error|
|
|
360
|
+
error ? reject.call(error) : resolve.call(notifications)
|
|
361
|
+
end
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
def render
|
|
365
|
+
suspense(fallback: -> { div { "Loading dashboard..." } }) do
|
|
366
|
+
# All three are loaded before this block executes
|
|
367
|
+
div do
|
|
368
|
+
h1 { "Welcome, #{user.name}" }
|
|
369
|
+
p { "Unread notifications: #{notifications.length}" }
|
|
370
|
+
p { "Total posts: #{stats.post_count}" }
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Reloading Data
|
|
378
|
+
|
|
379
|
+
Use `reload_suspense` to refresh data:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
def handle_refresh
|
|
383
|
+
reload_suspense(:user) # Reload specific data source
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def render
|
|
387
|
+
suspense(
|
|
388
|
+
fallback: -> { div { "Loading..." } },
|
|
389
|
+
error: ->(e) {
|
|
390
|
+
div do
|
|
391
|
+
span { "Error: #{e}" }
|
|
392
|
+
button(onclick: -> { reload_suspense(:user) }) { "Retry" }
|
|
393
|
+
end
|
|
394
|
+
}
|
|
395
|
+
) do
|
|
396
|
+
div do
|
|
397
|
+
div { user.name }
|
|
398
|
+
button(onclick: -> { handle_refresh }) { "Refresh" }
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Helper Methods
|
|
405
|
+
|
|
406
|
+
- `suspense_loading?` - Check if any suspense data is still loading
|
|
407
|
+
- `suspense_loading?(:name)` - Check if specific suspense data is loading
|
|
408
|
+
- `suspense_error?(:name)` - Check if suspense data failed to load
|
|
409
|
+
- `suspense_error(:name)` - Get the error for a specific suspense data
|
|
410
|
+
- `reload_suspense(:name)` - Reload specific suspense data
|
|
411
|
+
|
|
412
|
+
### When to Use Suspense
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# ✅ Use Suspense for: Initial data loading
|
|
416
|
+
use_suspense :user, ->(resolve, reject) { User.current(&resolve) }
|
|
417
|
+
|
|
418
|
+
# ❌ Don't use Suspense for: User actions (use normal state instead)
|
|
419
|
+
def handle_like(post_id)
|
|
420
|
+
Post.like(post_id) do |result, error|
|
|
421
|
+
# Just update state, don't use suspense
|
|
422
|
+
patch(liked: true)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## Best Practices
|
|
428
|
+
|
|
429
|
+
### 1. Use Models for CRUD Operations
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
# ✅ Good: Use Model layer
|
|
433
|
+
User.all do |users|
|
|
434
|
+
patch(users: users)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# ❌ Avoid: Manual HTTP for standard CRUD
|
|
438
|
+
Funicular::HTTP.get('/api/users') do |response|
|
|
439
|
+
patch(users: response.data)
|
|
440
|
+
end
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### 2. Use Suspense for Initial Loading
|
|
444
|
+
|
|
445
|
+
```ruby
|
|
446
|
+
# ✅ Good: Suspense for initial data
|
|
447
|
+
use_suspense :posts, ->(resolve, reject) {
|
|
448
|
+
Post.all(&resolve)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
# ❌ Avoid: Manual loading state management
|
|
452
|
+
def component_mounted
|
|
453
|
+
patch(is_loading: true)
|
|
454
|
+
Post.all do |posts|
|
|
455
|
+
patch(posts: posts, is_loading: false)
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### 3. Handle Errors Gracefully
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
# ✅ Good: Handle both success and error cases
|
|
464
|
+
User.create(form_data) do |user, errors|
|
|
465
|
+
if errors
|
|
466
|
+
patch(errors: errors, success_message: nil)
|
|
467
|
+
else
|
|
468
|
+
patch(user: user, errors: {}, success_message: "User created!")
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# ❌ Avoid: Ignoring errors
|
|
473
|
+
User.create(form_data) do |user, errors|
|
|
474
|
+
patch(user: user) # What if errors?
|
|
475
|
+
end
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### 4. Show Loading States
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
# ✅ Good: Clear loading feedback
|
|
482
|
+
def handle_submit(form_data)
|
|
483
|
+
patch(is_saving: true, errors: {})
|
|
484
|
+
|
|
485
|
+
Post.create(form_data) do |post, errors|
|
|
486
|
+
patch(is_saving: false, errors: errors || {})
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def render
|
|
491
|
+
form_for(:post, on_submit: :handle_submit) do |f|
|
|
492
|
+
f.submit(
|
|
493
|
+
state.is_saving ? "Saving..." : "Save",
|
|
494
|
+
disabled: state.is_saving
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### 5. Normalize Data Structures
|
|
501
|
+
|
|
502
|
+
```ruby
|
|
503
|
+
# ✅ Good: Normalize nested data
|
|
504
|
+
User.all do |users|
|
|
505
|
+
users_by_id = users.each_with_object({}) { |u, h| h[u.id] = u }
|
|
506
|
+
patch(users_by_id: users_by_id)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# ❌ Avoid: Deep nesting
|
|
510
|
+
patch(data: { users: { list: users, metadata: { ... } } })
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### 6. Cleanup Async Operations
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
def component_mounted
|
|
517
|
+
@request_in_flight = true
|
|
518
|
+
|
|
519
|
+
User.all do |users|
|
|
520
|
+
# Only update if component is still mounted
|
|
521
|
+
patch(users: users) if @request_in_flight
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def component_unmounted
|
|
526
|
+
@request_in_flight = false
|
|
527
|
+
end
|
|
528
|
+
```
|