Skip to content

Commit 58c41f8

Browse files
ealmloffjkelleyrtp
andauthored
Stores: Externally tracked state and reactive collections (#4483)
* map signals mutably * boxed signals * remove mutable borrow type from boxed writable * improve map mut example * restore boxed_mut helper * make boxed readable/writable types copy * Move away from the ReadOnlySignal alias in examples * automatically convert Write * Fix some tests * ReadSignal and WriteSignal * move read only signal alias * make maps copy * store prototype * implement derive macro * Handle generics in the derive macro * Make Selector boxable * derive boxed conversion * Get rid of store type * Remove hardcoded type from derive macro * selector -> store * Refactor store implementation * more vec methods * todo mvc store example * Implement Storable for HashMap * fix write * restore sorting logic in todomvc * derive readable and writable * improve todomvc store example * Result store * Option store * collect dead subscribers * fix clippy and formatting * recognize some common foreign types automatically * fix foreign stores in props * slice and array selectors * clean up store example * writable is child owned * box selector * read and write to builtins * re-use the proc macro in the derive macro * Switch to a extension trait based approach * switch to extension traits in the derive macro * transposed method * derive enums * Expose Store::new * remove stores from the prelude * More consistent naming scheme for SelectorScope methods * document hashmap methods * document index extension * document option ext * document result ext * document slice extension * document the vec extension * make more methods borrow the store mutably * Move to impls to have higher priority over the builtin write extension traits * fix deadlock * finish stores documentation * Document the store macro * cleanup docs and organization * finish derive docs * fix typo * fix clippy * fix doc tests * fix derive macro with generics * fix clippy * test and fix empty structs * clean up todomvc store example * default to a boxed store writer to make it easier to type when providing to the context api * fix cargo doc * fix clippy in store example * relax generational box lifetime bounds * relax bounds * relax signal bounds * fix compilation * read store * relax bounds on default store impls * fix enum derive * Convert to readsignal and writesignal automatically * simplify type matching logic * add write store * unsized stores * shrink the path vec type * use more concrete types * remove opaque types * remove copy bounds * use lens as generic name everywhere * more consistent borrow semantics * use a concrete type for the signal metadata and mention references in the map docs * improve todomvc store docs * document store derive macro internals * refactor macro * improve selector node documentation * fix clippy * add btreemap * fix doc tests * more option methods * fix doc * fix clippy * add a bunch more result methods * add a bunch of bounds to the iterators * fix clippy * remove option from subscribers * add store macro to generate extension traits * fix store example * fix deadlock * fix docs * fix doc tests * fix clippy * add stores to public api * add store macro, and then update todomvc_store example --------- Co-authored-by: Jonathan Kelley <[email protected]>
1 parent 5591070 commit 58c41f8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+4958
-436
lines changed

Cargo.lock

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ members = [
5252
"packages/rsx",
5353
"packages/server-macro",
5454
"packages/signals",
55+
"packages/stores",
56+
"packages/stores-macro",
5557
"packages/ssr",
5658
"packages/lazy-js-bundle",
5759
"packages/cli-config",
@@ -161,6 +163,8 @@ dioxus-rsx = { path = "packages/rsx", version = "0.7.0-alpha.3" }
161163
dioxus-rsx-hotreload = { path = "packages/rsx-hotreload", version = "0.7.0-alpha.3" }
162164
dioxus-rsx-rosetta = { path = "packages/rsx-rosetta", version = "0.7.0-alpha.3" }
163165
dioxus-signals = { path = "packages/signals", version = "0.7.0-alpha.3" }
166+
dioxus-stores = { path = "packages/stores", version = "0.7.0-alpha.3" }
167+
dioxus-stores-macro = { path = "packages/stores-macro", version = "0.7.0-alpha.3" }
164168
dioxus-cli-config = { path = "packages/cli-config", version = "0.7.0-alpha.3" }
165169
dioxus-cli-opt = { path = "packages/cli-opt", version = "0.7.0-alpha.3" }
166170
dioxus-devtools = { path = "packages/devtools", version = "0.7.0-alpha.3" }
@@ -427,6 +431,7 @@ wasm-splitter = { workspace = true, package = "wasm-split" }
427431

428432
[dev-dependencies]
429433
dioxus = { workspace = true, features = ["router"] }
434+
dioxus-stores = { workspace = true }
430435
dioxus-ssr = { workspace = true }
431436
futures-util = { workspace = true }
432437
separator = { workspace = true }

examples/todomvc_store.rs

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
//! The typical TodoMVC app, implemented in Dioxus with stores. Stores let us
2+
//! share nested reactive state between components. They let us keep our todomvc
3+
//! state in a single struct without wrapping every type in a signal while still
4+
//! maintaining fine grained reactivity.
5+
6+
use dioxus::prelude::*;
7+
use std::{collections::HashMap, vec};
8+
9+
const STYLE: Asset = asset!("/examples/assets/todomvc.css");
10+
11+
/// Deriving the store macro on a struct will automatically generate an extension trait
12+
/// for Store<TodoState> with method to zoom into the fields of the struct.
13+
///
14+
/// For this struct, the macro derives the following methods for Store<TodoState>:
15+
/// - `todos(self) -> Store<HashMap<u32, TodoItem>, _>`
16+
/// - `filter(self) -> Store<FilterState, _>`
17+
#[derive(Store, PartialEq, Clone, Debug)]
18+
struct TodoState {
19+
todos: HashMap<u32, TodoItem>,
20+
filter: FilterState,
21+
}
22+
23+
// We can also add custom methods to the store by using the `store` attribute on an impl block.
24+
// The store attribute turns the impl block into an extension trait for Store<TodoState>.
25+
// Methods that take &self will automatically get a bound that Lens: Readable<Target = TodoState>
26+
// Methods that take &mut self will automatically get a bound that Lens: Writable<Target = TodoState>
27+
#[store]
28+
impl<Lens> Store<TodoState, Lens> {
29+
fn active_items(&self) -> Vec<u32> {
30+
let filter = self.filter().cloned();
31+
let mut active_ids: Vec<u32> = self
32+
.todos()
33+
.iter()
34+
.filter_map(|(id, item)| item.active(filter).then_some(id))
35+
.collect();
36+
active_ids.sort_unstable();
37+
active_ids
38+
}
39+
40+
fn incomplete_count(&self) -> usize {
41+
self.todos()
42+
.values()
43+
.filter(|item| item.incomplete())
44+
.count()
45+
}
46+
47+
fn toggle_all(&mut self) {
48+
let check = self.incomplete_count() != 0;
49+
for item in self.todos().values() {
50+
item.checked().set(check);
51+
}
52+
}
53+
54+
fn has_todos(&self) -> bool {
55+
!self.todos().is_empty()
56+
}
57+
}
58+
59+
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
60+
enum FilterState {
61+
All,
62+
Active,
63+
Completed,
64+
}
65+
66+
#[derive(Store, PartialEq, Clone, Debug)]
67+
struct TodoItem {
68+
checked: bool,
69+
contents: String,
70+
}
71+
72+
impl TodoItem {
73+
fn new(contents: impl ToString) -> Self {
74+
Self {
75+
checked: false,
76+
contents: contents.to_string(),
77+
}
78+
}
79+
}
80+
81+
#[store]
82+
impl<Lens> Store<TodoItem, Lens> {
83+
fn complete(&self) -> bool {
84+
self.checked().cloned()
85+
}
86+
87+
fn incomplete(&self) -> bool {
88+
!self.complete()
89+
}
90+
91+
fn active(&self, filter: FilterState) -> bool {
92+
match filter {
93+
FilterState::All => true,
94+
FilterState::Active => self.incomplete(),
95+
FilterState::Completed => self.complete(),
96+
}
97+
}
98+
}
99+
100+
fn main() {
101+
dioxus::launch(app);
102+
}
103+
104+
fn app() -> Element {
105+
// We store the state of our todo list in a store to use throughout the app.
106+
let mut todos = use_store(|| TodoState {
107+
todos: HashMap::new(),
108+
filter: FilterState::All,
109+
});
110+
111+
// We use a simple memo to calculate the number of active todos.
112+
// Whenever the todos change, the active_todo_count will be recalculated.
113+
let active_todo_count = use_memo(move || todos.incomplete_count());
114+
115+
// We use a memo to filter the todos based on the current filter state.
116+
// Whenever the todos or filter change, the filtered_todos will be recalculated.
117+
// Note that we're only storing the IDs of the todos, not the todos themselves.
118+
let filtered_todos = use_memo(move || todos.active_items());
119+
120+
// Toggle all the todos to the opposite of the current state.
121+
// If all todos are checked, uncheck them all. If any are unchecked, check them all.
122+
let toggle_all = move |_| {
123+
todos.toggle_all();
124+
};
125+
126+
rsx! {
127+
document::Link { rel: "stylesheet", href: STYLE }
128+
section { class: "todoapp",
129+
TodoHeader { todos }
130+
section { class: "main",
131+
if todos.has_todos() {
132+
input {
133+
id: "toggle-all",
134+
class: "toggle-all",
135+
r#type: "checkbox",
136+
onchange: toggle_all,
137+
checked: active_todo_count() == 0
138+
}
139+
label { r#for: "toggle-all" }
140+
}
141+
142+
// Render the todos using the filtered_todos memo
143+
// We pass the ID along with the hashmap into the TodoEntry component so it can access the todo from the todos store.
144+
ul { class: "todo-list",
145+
for id in filtered_todos() {
146+
TodoEntry { key: "{id}", id, todos }
147+
}
148+
}
149+
150+
// We only show the footer if there are todos.
151+
if todos.has_todos() {
152+
ListFooter { active_todo_count, todos }
153+
}
154+
}
155+
}
156+
157+
// A simple info footer
158+
footer { class: "info",
159+
p { "Double-click to edit a todo" }
160+
p {
161+
"Created by "
162+
a { href: "http://github.com/jkelleyrtp/", "jkelleyrtp" }
163+
}
164+
p {
165+
"Part of "
166+
a { href: "http://todomvc.com", "TodoMVC" }
167+
}
168+
}
169+
}
170+
}
171+
172+
#[component]
173+
fn TodoHeader(mut todos: Store<TodoState>) -> Element {
174+
let mut draft = use_signal(|| "".to_string());
175+
let mut todo_id = use_signal(|| 0);
176+
177+
let onkeydown = move |evt: KeyboardEvent| {
178+
if evt.key() == Key::Enter && !draft.read().is_empty() {
179+
let id = todo_id();
180+
let todo = TodoItem::new(draft.take());
181+
todos.todos().insert(id, todo);
182+
todo_id += 1;
183+
}
184+
};
185+
186+
rsx! {
187+
header { class: "header",
188+
h1 { "todos" }
189+
input {
190+
class: "new-todo",
191+
placeholder: "What needs to be done?",
192+
value: "{draft}",
193+
autofocus: "true",
194+
oninput: move |evt| draft.set(evt.value()),
195+
onkeydown
196+
}
197+
}
198+
}
199+
}
200+
201+
/// A single todo entry
202+
/// This takes the ID of the todo and the todos store as props
203+
/// We can use these together to memoize the todo contents and checked state
204+
#[component]
205+
fn TodoEntry(mut todos: Store<TodoState>, id: u32) -> Element {
206+
let mut is_editing = use_signal(|| false);
207+
208+
// When we get an item out of the store, it will only subscribe to that specific item.
209+
// Since we only get the single todo item, the component will only rerender when that item changes.
210+
let entry = todos.todos().get(id).unwrap();
211+
let checked = entry.checked();
212+
let contents = entry.contents();
213+
214+
rsx! {
215+
li {
216+
// Dioxus lets you use if statements in rsx to conditionally render attributes
217+
// These will get merged into a single class attribute
218+
class: if checked() { "completed" },
219+
class: if is_editing() { "editing" },
220+
221+
// Some basic controls for the todo
222+
div { class: "view",
223+
input {
224+
class: "toggle",
225+
r#type: "checkbox",
226+
id: "cbg-{id}",
227+
checked: "{checked}",
228+
oninput: move |evt| entry.checked().set(evt.checked())
229+
}
230+
label {
231+
r#for: "cbg-{id}",
232+
ondoubleclick: move |_| is_editing.set(true),
233+
onclick: |evt| evt.prevent_default(),
234+
"{contents}"
235+
}
236+
button {
237+
class: "destroy",
238+
onclick: move |evt| {
239+
evt.prevent_default();
240+
todos.todos().remove(&id);
241+
},
242+
}
243+
}
244+
245+
// Only render the actual input if we're editing
246+
if is_editing() {
247+
input {
248+
class: "edit",
249+
value: "{contents}",
250+
oninput: move |evt| entry.contents().set(evt.value()),
251+
autofocus: "true",
252+
onfocusout: move |_| is_editing.set(false),
253+
onkeydown: move |evt| {
254+
match evt.key() {
255+
Key::Enter | Key::Escape | Key::Tab => is_editing.set(false),
256+
_ => {}
257+
}
258+
}
259+
}
260+
}
261+
}
262+
}
263+
}
264+
265+
#[component]
266+
fn ListFooter(mut todos: Store<TodoState>, active_todo_count: ReadSignal<usize>) -> Element {
267+
// We use a memo to calculate whether we should show the "Clear completed" button.
268+
// This will recompute whenever the number of todos change or the checked state of an existing
269+
// todo changes
270+
let show_clear_completed = use_memo(move || todos.todos().values().any(|todo| todo.complete()));
271+
let mut filter = todos.filter();
272+
273+
rsx! {
274+
footer { class: "footer",
275+
span { class: "todo-count",
276+
strong { "{active_todo_count} " }
277+
span {
278+
match active_todo_count() {
279+
1 => "item",
280+
_ => "items",
281+
}
282+
" left"
283+
}
284+
}
285+
ul { class: "filters",
286+
for (state , state_text , url) in [
287+
(FilterState::All, "All", "#/"),
288+
(FilterState::Active, "Active", "#/active"),
289+
(FilterState::Completed, "Completed", "#/completed"),
290+
] {
291+
li {
292+
a {
293+
href: url,
294+
class: if filter() == state { "selected" },
295+
onclick: move |evt| {
296+
evt.prevent_default();
297+
filter.set(state)
298+
},
299+
{state_text}
300+
}
301+
}
302+
}
303+
}
304+
if show_clear_completed() {
305+
button {
306+
class: "clear-completed",
307+
onclick: move |_| todos.todos().retain(|_, todo| !todo.checked),
308+
"Clear completed"
309+
}
310+
}
311+
}
312+
}
313+
}

0 commit comments

Comments
 (0)