-
-
Notifications
You must be signed in to change notification settings - Fork 332
add class based component implementation #518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
I'd suggest removing the decorators ( Inheritance would better align with common pythonic styling. # Calling this `BasicComponent` in case other component models exist in the future,
# `Component` would be the base class to `BasicComponent`
from idom import BasicComponent
class Counter(BasicComponent):
... |
|
On a different note, I definitely think this is vastly more beginner friendly compared to the functional components. If we wanted to flesh this out later, perhaps some of the hook functionality could be prototyped functions to allow for type hinting. In order to figure out what IDOM has to offer, type hinting is much quicker than scrolling through docs. |
|
I am somewhat worried about confusion over mutable attributes here. For example, you cannot simple append to a list and get a re-render, you need to re-assign the attribute. I might actually make an |
|
How about instead of We can just require this to be called rather than implicitly doing it via self.count += 1
self.update()In the case that the user has a ton of attributes to update, this would feel a lot cleaner. Plus, this follows the pattern of most Python ORMs. |
|
Regarding composable parts, wouldn't it be possible to simply create any reusable functions outside the class? For example, There isn't any overhead for using an external function, as it's accessed via reference by the interpreter. |
|
So there are some differences in composability that become more evident as things get more complex. Imagine that I want a simple component that counts down from some number and has a button to reset the count: Using hooks that might be done as follows: @idom.component
def CountDown():
count, reset_count = use_count_down(5)
return idom.html.div(
idom.html.button({"onClick": lambda event: reset_count()}, "reset"),
f"count: {count}",
)
def use_count_down(initial_count):
count, set_count = idom.hooks.use_state(initial_count)
@idom.hooks.use_effect
async def decrement():
await asyncio.sleep(1)
if count > 0:
set_count(count - 1)
return count, lambda: set_count(initial_count)Assuming there were an class CountDown(ClassComponent):
def __init__(self, initial_count):
self.initial_count = self.count = initial_count
def render(self):
return idom.html.div(
idom.html.button({"onClick": self.reset_count}, "reset"),
f"count: {self.count}",
)
async def effect(self):
await asyncio.sleep(1)
if self.count > 0:
self.count -= 1
self.update()
def reset_count(self):
self.count = self.initial_count
self.update()It's not clear to me how you could abstract something similar to the |
|
Regardless though, I'm less worried about trying encourage good patterns of development, and more concerned with how to explain why we have two ways of doing the same thing. Even if the learning curve is steeper, in the long run, it may be beneficial to only have one thing you need to master. |
|
6568f86 to
dc54bed
Compare
|
So this last round of changes modifies the usage a fair bit with the main goal of helping the user avoid mutating their state. This is done by:
Both these changes bring this implementation more in-line with React's own class-based components. Ultimately, these result in the following usage: from typing import NamedTuple
import idom
class GateState(NamedTuple):
left: bool
right: bool
@idom.component
class AndGate:
def __init__(self):
self.state = GateState(False, False)
def render(self):
left, right = self.state
return idom.html.div(
idom.html.input({"type": "checkbox", "onClick": self.toggle_left}),
idom.html.input({"type": "checkbox", "onClick": self.toggle_right}),
idom.html.pre(f"{left} AND {right} = {left and right}"),
)
def toggle_left(self, event):
left, right = self.state
idom.update(self, GateState(not left, right))
def toggle_right(self, event):
left, right = self.state
idom.update(self, GateState(left, not right))The first change collects all state changes between renders into one location. This will hopefully make it easier to both understand the code and perhaps allow for code refactors that can focus on operating on state with pure functions. The example below is a bit contrived, but it gets the idea across: def toggle_gate_state(state: GateState, which: str) -> GateState:
attrs = state._asdict()
attrs[which] = not attrs[which]
return GateState(**attrs)The second change, while different from React, helps to avoid the implication that calling this.state.x == 0
this.setState({x: 1})
this.state.x == 1 // not trueThis behavior may seem unintuitive, but its purpose is to isolate each evolution of state to its associated render. |
|
I agree with those changes. I'd only recommend stylistic tweaks.
My proposed styling would look like this from typing import NamedTuple
from idom import Component
import idom
class GateState(NamedTuple):
left: bool = False
right: bool = False
class AndGate(Component):
state = GateState()
def render(self):
left, right = self.state
return idom.html.div(
idom.html.input({"type": "checkbox", "onClick": self.toggle_left}),
idom.html.input({"type": "checkbox", "onClick": self.toggle_right}),
idom.html.pre(f"{left} AND {right} = {left and right}"),
)
def toggle_left(self, event):
new_state = GateState(not self.state.left, self.state.right)
idom.set_state(self, new_state)
def toggle_right(self, event):
new_state = GateState(self.state.left, not self.state.right)
idom.set_state(self, new_state)Preventing Bad PracticesWe could actually wrap the current state to print a warning (or throw an exception) if the user tries to directly modify the values of Also perhaps a warning/exception if the user replaces the state with something that isn't the same |
|
An alternative is to do the This would enforce a function call to utilize I'd only implement it this way if updating the state twice within a single function call wouldn't operate as expected due to technical limitations. And if effect hooks wouldn't be impacted. ...
class AndGate(Component):
...
@idom.set_state
def toggle_left(self, event):
return GateState(not self.state.left, self.state.right)
@idom.set_state
def toggle_right(self, event):
return GateState(self.state.left, not self.state.right) |
Base Class vs DecoratorI think I prefer the
Put State Declaration at Class LevelUnfortunately this doesn't quite work in cases where the initial state depends on parameters from import idom
@idom.component
class Whatever:
state = State()
@state.init
def init_state():
...
@state.change
def change_it_somehow(self, state):
...
|
|
After looking into it for a bit, it turns out that making |
|
If I understand correctly, it sounds like the only styling change thats debated right now is option 3. Honestly using a decorator on a class isn't bad, but they're so uncommon that it might feel strange to new devs. If there's going to be significant issues with the flake8 hook when using inheritance then I'm not against the decorator. |
dc54bed to
3869d02
Compare
|
I'm hoping that you'll be able to use hooks with class-based components. To do that, I'll need to enforce the rules of hooks in the |
|
A few more slight changes:
With these changes I was able to rewrite the snake example: https://gist.github.com/rmorshea/84d66b2ef61ae38171c8066bb4368d36 |
3869d02 to
974b7d4
Compare
|
Reading through the snake game rewrite. I dig it, definitely improves readability of the Couple of thoughts.
Example solutions to the lambda struggle# I don't think any of my suggestions here are ideal
# Can't really think of a way other than light wrappers for lambda though.
# But long term there should be thought of reducing/removing reliance on lambda.
# Current Design
{"onClick": lambda event: idom.set_state(self, GameState.play)}
# Suggested Design(s)
{"onClick": idom.attribute(idom.set_state, self, GameState.play) )}
{"onClick": idom.func(idom.set_state, self, GameState.play) )}
{"onClick": idom.execute(idom.set_state, self, GameState.play) )}
{"onClick": idom.html.func(idom.set_state, self, GameState.play) )}
{"onClick": idom.html.python(idom.set_state, self, GameState.play) )} |
|
Does IDOM functional components support multiple of the same hooks? For example, having two |
|
Yes. For class-based components to accomplish the same thing you'll have to do that manually from one method: async def mount_effect(self):
await asyncio.gather(
self.some_effect(),
self.another_effect(),
)
return self.cleanup_mount_effect
def cleanup_mount_effect(self):
self.cleanup_some_effect()
self.cleanup_another_effect() |
|
This PR should probably be shelved and looked at within the v3 release cycle. |
|
I'm going to close this for now. |


This pull request is provisional, and it may never be merged. Hopefully it can serve as a useful reference if this is asked about in the future.
What This Does
This PR allows you to define components using classes similarly to how you would in React. Python's
__setattr__magic method makes it a bit easier to implement though since you no longer need anupdatemethod. Instead the instance attributes can be assigned directly.Limitations
This PR does not allow class-based components to incur side effects. We may leave that as an intentional limitation though in order to try and push people towards functional components because they encourage better patterns of development.
Disadvantages
Advantages
Counterexample above a parent component could increment the count by mutating its attributes.