State management of a todo list

Hi all! I am trying to understand state management in panel, and what better way than creating a todo list!
I am able to use parameterized classes to create a stateful list that can be added to or deleted from.

It looks like this:

I’ve created two components, the todo list itself and a component for the individual element in the list. Each individual list element has its own delete button, but I can’t figure out a clean way to pass the the action of deleting an individual element up to the parent list. I’ve had to resort to adding a “delete” button and a “remove” Select widget to the list object itself, but that is not my preference.

Any experts out there know how to enable the delete buttons on each of the list items?

The code to create the above todo list:

import panel as pn
import param
pn.extension()

class TodoList(param.Parameterized):
    """ Class to manage state of a List of items """
    def _record_todo(self):
        # Record new item if its unique and non-empty 
        if len(self.todo) > 0 and self.todo not in [i.todo for i in self.todo_list]:
            self.todo_list = (
                [i for i in self.todo_list if len(i.todo) > 0] + 
                [TodoListItem(todo=self.todo, name="")])
            self.todo = ''
            self.param.remove.objects = [todo.todo for todo in self.todo_list]
            self.remove = self.param.remove.objects[0]
    record_todo = param.Action(_record_todo)
    
    def _delete_todo(self):
        self.todo_list = [i for i in self.todo_list if i.todo != self.remove]
        self.param.remove.objects = [todo.todo for todo in self.todo_list]
        self.remove = self.param.remove.objects[0]
    delete_todo = param.Action(_delete_todo)
    
    todo_list = param.List([])
    todo = param.String()
    remove = param.ObjectSelector('', objects=[])

    
class TodoListItem(param.Parameterized):
    """ Class to manage a a single list element """
    def delete_todo(self):
        print("How can I signal to the TodoList that I want to be deleted?")
    done = param.Action(delete_todo)
    todo = ""
    
todo_list = TodoList()

@pn.depends(todo_list.param.todo_list)
def view_list(todo_list):
    todo_text = lambda x : pn.pane.Markdown("###" + x, width=200)
    delete_button = lambda x : pn.Param(
        x, widgets={'done': {'widget_type' : pn.widgets.Button,
                             'button_type': 'danger', 'name':'x', 'width':15}})
    return pn.Column(*[pn.Row(todo_text(todo.todo), delete_button(todo.param.done)) 
                       for todo in todo_list])

pn.Column(
    pn.pane.Markdown('## My To-Do List'),
    pn.Row(pn.Param(todo_list.param.todo, 
                    widgets={'todo': {'widget_type' : pn.widgets.TextInput, 'name': "", 
                                      'placeholder': 'Enter a Todo...'}}), 
           pn.Param(todo_list.param.record_todo,
                    widgets={'record_todo': {'widget_type' : pn.widgets.Button,
                                             'button_type': 'primary', 'name':'+', 'width':15}})),
    view_list, 
    pn.Row(
        pn.Param(todo_list.param.remove),
        pn.Param(todo_list.param.delete_todo, 
                 widgets={'delete_todo': {'widget_type' : pn.widgets.Button,
                                          'button_type': 'danger', 'name':'x', 'width':15, 'margin': (20, 10,20,10)}}))
).servable()

I see two approaches

  1. When you create a new TodoListItem you set a todo_list parameter such that it knows which TodoList it is attached to. Then the TodoListItem can remove it self from the list.

  2. The TodoListItem can trigger a delete event (use param.Event) when it wants to remove it self from any list. The TodoList can listen for this event and remove the TodoListItem.

If you end up with a nice a implementation please consider whether a PR to the Panel Gallery would be valuable. It is just adding a notebook to the repository of Panel. Thanks.

1 Like

Based on second suggestion of @Marc I’ll do something like that:

import panel as pn
import param
pn.extension()

class TodoList(param.Parameterized):
    """ Class to manage state of a List of items """
    def _record_todo(self):
        # Record new item if its unique and non-empty 
        if len(self.todo) > 0 and self.todo not in [i.todo for i in self.todo_list]:
            new_item = TodoListItem(parent=self, todo=self.todo, name="")
            new_item.param.watch(self._delete_item, ['done'])
            self.todo_list.append(new_item)
            self.param.trigger('todo_list')
            self.todo = ''
    record_todo = param.Action(_record_todo)
    
    def _delete_item(self, *events):
        for event in events:
            if event.name == 'done':
                self.todo_list.remove(event.obj)
                self.param.trigger('todo_list')
    
    todo_list = param.List([])
    todo = param.String()

    
class TodoListItem(param.Parameterized):
    """ Class to manage a a single list element """
    done = param.Event()
    todo = ""
    
todo_list = TodoList()

@pn.depends(todo_list.param.todo_list)
def view_list(todo_list):
    todo_text = lambda x : pn.pane.Markdown("###" + x, width=200)
    delete_button = lambda x : pn.Param(
        x, widgets={'done': {'widget_type' : pn.widgets.Button,
                             'button_type': 'danger', 'name':'x', 'width':15}})
    return pn.Column(*[pn.Row(todo_text(todo.todo), delete_button(todo.param.done)) 
                       for todo in todo_list])

pn.Column(
    pn.pane.Markdown('## My To-Do List'),
    pn.Row(pn.Param(todo_list.param.todo, 
                    widgets={'todo': {'widget_type' : pn.widgets.TextInput, 'name': "", 
                                      'placeholder': 'Enter a Todo...'}}), 
           pn.Param(todo_list.param.record_todo,
                    widgets={'record_todo': {'widget_type' : pn.widgets.Button,
                                             'button_type': 'primary', 'name':'+', 'width':15}})),
    view_list,
).servable()
4 Likes

What a beatiful solution. I would have never been able to figure that out. I’ll wrap this in a more presentable PR for the docs. I think its a good one to have.

It took me a while to realize that parameterized classes are a mechanism for creating stateful components. This is a major topic in tools like reactJS, but only subtlely referenced in the panel docs. Maybe I didn’t see it because my own mental model of how it should work is clouded by reactJS. However, I think they touch on good points. Namely:

  1. Any reasonably sophisticated app will require stateful components
  2. State management is wrought with peril and needs lots of examples and best practices

It’s amazing that Panel can deal with state so effectively already. That makes it so much more powerful than is implied by the docs.

2 Likes

@jbogaardt. For me Param is also one of the fundamental reasons why I like Panel over so many of the competing frameworks out there. But maybe it’s because I am old school or have inspiration from front end frameworks. What I see is that most users come from from an engineering or (data) science background. And they like working with functions and not so much with classes.

But @jbogaardt. If you want more React like functionality check out

Testing ReactiveHTML and adding awesome examples to the gallery is something that would really be appriciated I believe :slight_smile:

My personal take is also that the API could need a bit of inspiration from someone with React experience.

1 Like

Really nice idea to implement it. I come from a physics background and I do not really like classes :(. I do not understand them…

I implemented the same thing only with functions. Probably it is not so performant like the other approach, but the state is simply managed with a dictionary. When you have a large app you can return the dictionary to other places of your code in a simple way, or even you can create the dictionary in a higher scope (even global), and in that way you have a shared or global state. I use dictionaries to update the status of different components from different threads without any problem until now (I think it is thread-safe), and I think for begginers is really easier.

import panel as pn
css = '''
.bk.panel-widget-box {
  background: #f0f0f0;
  border-radius: 5px;
  border: 1px black solid;    }    '''
pn.extension(raw_css=[css])

def ToDoList(name):
    state = {}
    title = pn.pane.Markdown('#' + name)
    col_items = pn.Column()

    def get_add_component():
        add_input = pn.widgets.TextInput(name='Add item', width=300, placeholder= 'to do list items')
        add_button = pn.widgets.Button(name='+', width=15, button_type='success', align='end')
        
        def add_item(event):
            if add_input.value not in state.keys() and add_input.value is not '':
                state[add_input.value] = False
                col_items[:] = [*view_list()]
                add_input.value = '' 
        add_button.on_click(add_item)
    
        @pn.depends(add_input.param.value, watch=True)
        def add_item_from_enter(value):
            if value not in state.keys() and value is not '':
                state[value] = False
                col_items[:] = [*view_list()]
                add_input.value = ''            

        return pn.Row(add_input, add_button)

    add = get_add_component()

    def get_item_list(text, done):
        print ('item list', text,done)
        list_done = pn.widgets.Checkbox(value=done, width=15, align='end')
        list_text = pn.pane.Markdown('### '+ text, width=275, 
                    margin=(0, 0, -8, 0), align='end')
        list_remove = pn.widgets.Button(name='x', width=15, button_type='danger', align='end')
        
        @pn.depends(list_done.param.value, watch=True)
        def update_done_state(value):
            state[text] = list_done.value
            print ('changing done state', text, list_done.value)

        def delete_item(event):
            if text in state.keys():
                del state[text]
                col_items[:] = [*view_list()]
        list_remove.on_click(delete_item)
        
        return pn.Row(list_done, list_text, list_remove)

    def view_list():
        items=[]
        for k,v in state.items():
            print ('view list', k,v)
            items.append(get_item_list(k,v))
        return pn.Column(*items)

    return pn.Column(title, add,
                    col_items,
                    width=380,
                    css_classes=['panel-widget-box'])

Lista = ToDoList('To Do List')        
Lista.servable()
3 Likes

I’ll read up on these.

1 Like

Nicely done @nghenzi. The functional perspective is useful as a comparison and I’m trying to convince myself of one approach vs the other. In the context of the todo list, it works just as well!

I think one major difference is that the state of parameterized instance can be directly depended on @pn.depends(todo_list.param.todo_list). This allows other unrelated components to react to a change in the todo_list. I’m imagining that down the line, I may want a separate calendar component that allows me to assign the todos to some future due date. With the functional form, it feels like I’d have to bake that separate view into the existing component. With the class component, I can leave the todo list alone and just ask it for its state and create a new independent calendar component that depends on it.

I think the parameterized class also allows me to modify the state of the todo list from outside of the component. For example, I might want to have a separate component to “delete all”: the list. Again, I can leave the list alone and just modify its state like so: todo_list.todo_list = []. I’m not sure how I would do this in the functional example.

I’m still learning this stuff, but it seems complexity can arise when independent components need to share/modify state and since functions have no state (at least no shareable state), they cannot accomplish what the parameterized class can.

2 Likes

I see the point, I will watch this parameterized classes. The possibility to listen to changes of the todo_list seems appealing.

2 Likes

Thank you for your explanation. After digging a bit, if I replace the python dict for a param.Dict({}) I could get a shareable, editable and dependeable state for other isolated components.

image

The code is below

import param
import panel as pn

class Dic(param.Parameterized):
    d = param.Dict({})

def ToDoList(name): 
    state = Dic()

    def get_add_component():
        add_input = pn.widgets.TextInput(name='Add item', width=300, placeholder= 'to do list items')
        add_button = pn.widgets.Button(name='+', width=15, button_type='success', align='end')

        def add_item(event):
            if add_input.value not in list(state.d.keys()) and add_input.value != '':
                state.d = { **state.d, add_input.value:(False, None)}
                add_input.value = '' 
        add_button.on_click(add_item)
        
        @pn.depends(add_input.param.value, watch=True)
        def add_item_from_enter(value):
            if value not in state.d.keys() and value != '':
                state.d = { **state.d, value:(False, None)}
                add_input.value = ''
        return pn.Row(add_input, add_button)
   
    def get_item_list(text, done, dat):
        done = pn.widgets.Checkbox(value=done, width=15, align='end')
        textw = pn.pane.Markdown('### '+ text, width=155, margin=(0, 0, -8, 0), align='end')
        date = pn.widgets.DatePicker(width=110, value=dat)
        remove = pn.widgets.Button(name='x', width=15, button_type='danger', align='end')

        @pn.depends(done.param.value, watch=True)
        def update_done_state(value):
            state.d = {k:(value, v[1]) if k==text else v for k,v in state.d.items() }

        @pn.depends(date.param.value, watch=True)
        def update_done_state(value):
            state.d = {k:(v[0], value) if k==text else v for k,v in state.d.items() }

        def delete_item(event):
            state.d = {k:v for k,v in state.d.items() if k!=text}
        remove.on_click(delete_item)

        return pn.Row(done, textw, date, remove)

    @pn.depends(state.param.d)
    def view_list(val):
        return pn.Column(*[get_item_list(k,v[0], v[1]) for k,v in state.d.items()])

    return get_add_component(), view_list, state

add_component, view_list, state = ToDoList('list')

@pn.depends(state.param.d, watch=True)
def trigger(value):
    print ('triggering for child component', value)

btn = pn.widgets.Button(name ='delete all', button_type='danger')
def delete_all(event):
    state.d = {}
btn.on_click(delete_all)

row = pn.Row( pn.Column(add_component, view_list),
              pn.Spacer(width=15),  
              pn.Column(state.param.d,btn)                
            )

tmpl = pn.template.VanillaTemplate(title='State management in a To Do List',
    theme= pn.template.DarkTheme)
tmpl.main.append(row)
tmpl.servable()
3 Likes

Nice example! Next question: how to add an element with an keystroke event without adding an element when losing the focus (meaning watching self.todo isn’t the solution) ?
I believe we need to add javascript code for this, there is no focus event in bokeh.

As for the topic of functional vs class approach, I have an physics engineering/scientific background and I prefer the class approach whenever it’s about saving properties & parameters within a single concept (like here). I believe it’s more a question of passing the learning curve than about backgrounds.

3 Likes

Using the reactiveHTML class you could maybe obtained what you want:
key_input

class Input(ReactiveHTML):
    
    value = param.String()
    
    _html = """
        <div class="bk bk-input-group">
            <input class="bk bk-input" id="input" value="${value}" style="margin: 5px;" onkeydown="${_input_keydown}"></input>
        </div>
    """
    
    def _input_keydown(self, event):
        print(event.data['key'])
    
i = Input(sizing_mode="stretch_width", max_width=300)
i
2 Likes

That looks like a very fresh addition to Panel. I’m eager to see it go into the main branch.

1 Like

when in main branch It could look like this
todo

import panel as pn
import param
from panel.reactive import ReactiveHTML
pn.extension()

class CustomInputTodoList(ReactiveHTML):
    
    value = param.String()
    
    enter_event = param.Event()
    
    _template = """
        <div class="bk bk-input-group">
            <input class="bk bk-input" id="input" value="${value}" style="margin: 5px;" onkeydown="${_input_keydown}"></input>
        </div>
    """
    
    _dom_events = {"input": ["input"]}
    
    def __init__(self, todolist, **params):
        self._todolist = todolist
        super().__init__(**params)
    
    def _input_keydown(self, event):
        if event.data['key'] == "Enter":
            self._todolist.todo = self.value
            self.value = ""
 

class TodoList(param.Parameterized):
    """ Class to manage state of a List of items """
    @param.depends("todo", watch=True)
    def _record_todo(self):
        # Record new item if its unique and non-empty
        value = self.todo.strip()
        if len(value) > 0 and value not in [i.todo for i in self.todo_list]:
            new_item = TodoListItem(parent=self, todo=value, name="")
            new_item.param.watch(self._delete_item, ['done'])
            self.todo_list.append(new_item)
            self.param.trigger('todo_list')
    
    def _delete_item(self, *events):
        for event in events:
            if event.name == 'done':
                self.todo_list.remove(event.obj)
                self.param.trigger('todo_list')
    
    todo_list = param.List([])
    todo = param.String()

    
class TodoListItem(param.Parameterized):
    """ Class to manage a a single list element """
    done = param.Event()
    todo = ""
    
todo_list = TodoList()
input_todo_list = CustomInputTodoList(todo_list, width=300)

@pn.depends(todo_list.param.todo_list)
def view_list(todo_list):
    todo_text = lambda x : pn.pane.Markdown("###" + x, width=200)
    delete_button = lambda x : pn.Param(
        x, widgets={'done': {'widget_type' : pn.widgets.Button,
                             'button_type': 'danger', 'name':'x', 'width':15}})
    return pn.Column(*[pn.Row(todo_text(todo.todo), delete_button(todo.param.done)) 
                       for todo in todo_list])

pn.Column(
    pn.pane.Markdown('## My To-Do List'),
    pn.Row(input_todo_list, width=300),
    view_list,
).servable()
6 Likes