Introducing effector-dom using the example task list

Many already know the effector state manager , someone not only watched it, but also used it in the prod. Since the end of autumn, its author has been actively developing devtools for an effector, and in the process of this work he managed to write a very interesting library for rendering an application - effector-dom .


We will get to know this render - in this tutorial we will create a simple Todo application.



To work with logic, we will use effector , for rendering the application - effector-dom .


For the visual part, let's take the ready-made todomvc-app-template template with todomvc-app-css styles by tastejs as the basis .


1. Project preparation


I assume that you are already familiar with webpack and npm, so we skip the step of installing npm, creating a project with webpack and launching the application (if we are not familiar, google webpack boilerplate).


Install the necessary packages with the versions that were used at the time of writing:
npm install effector@20.11.5 effector-dom@0.0.10 todomvc-app-css


2. Let's start


First, let's determine the structure of the application.


, , , - best practices .


, .


:


srs/
  view/
    app.js //   
    title.js // 
    footer.js //  ,  ,   
    header.js //   ,   
    main.js //  
    todoItem.js //  ,   
  model.js //  
  index.js //   

3.


, : , , .


( undefined) Store, — Event.


combine, .


// src/model.js
import {createStore, createEvent, combine} from 'effector';

// 

//  
export const $todos = createStore([]); 

//  ,    null/true/false
export const $activeFilter = createStore(null); 

//  
export const $filteredTodos = combine(
  $todos,
  $activeFilter,
  (todos, filter) => filter === null
    ? todos
    : todos.filter(todo => todo.completed === filter)
);

// 

//   
export const appended = createEvent(); 

// /  
export const toggled = createEvent();

//  
export const removed = createEvent();

//    
export const allCompleted = createEvent();

//   
export const completedRemoved = createEvent();

//  
export const filtered = createEvent();

.
, store.on


// src/model.js
...

$todos
  //   
  .on(appended, (state, title) => [...state, {title, completed: false}])
  //  .     title
  .on(removed, (state, title) => state.filter(item => item.title !== title)) 
  // / 
  .on(toggled, (state, title) => state.map(item => item.title === title 
    ? ({...item, completed: !item.completed})
    : item))
  //   
  .on(allCompleted, state => state.map(item => item.completed
    ? item
    : ({...item, completed: true})))
  //   
  .on(completedRemoved, state => state.filter(item => !item.completed));

$activeFilter
  // 
  .on(filtered, (_, filter) => filter);

. .


4. view


view effector-dom. , SwiftUI .


, , . dom- .


effector-dom dom , . using, dom- , :


// src/index.js
import {using} from 'effector-dom';
import {App} from './view/app';

using(document.body, () => {
  App();
});

h, dom- , .


dom- spec, :


// src/view/app.js
import {h, spec} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {Header} from './header';
import {Main} from './main';
import {Footer} from './footer';

export const App = () => {
  //  section 
  h('section', () => {
    //    
    spec({attr: {class: classes.todoapp}});

    //     
    Header();
    Main();
    Footer();
  });
};

.


5.


h1 .


dom- , , . spec , :


// src/view/title.js
import {h} from 'effector-dom';

export const Title = () => {
  h('h1', {text: 'todos'});
};

6.


, effector-dom effector , , ..


, header. dom- spec ( ), handler.


, input , $value input.


sample . api sample — , — , : event = sample($store, triggerEvent).


// src/view/header.js
import {h, spec} from 'effector-dom';
import {createEvent, createStore, forward, sample} from 'effector';
import classes from 'todomvc-app-css/index.css';
import {Title} from './title';
import {appended} from '../model';

export const Header = () => {
  h('header', () => {
    Title();

    h('input', () => {
      const keypress = createEvent();
      const input = createEvent();

      //   ,
      const submit = keypress.filter({fn: e => e.key === 'Enter'});

      //     
      const $value = createStore('')
        .on(input, (_, e) => e.target.value)
        .reset(appended); //    

      //         forward({from, to})
      forward({ 
        //    $value   submit,
        //       
        from: sample($value, submit).filter({fn: Boolean}), 
        to: appended,
      });

      spec({
        attr: {
          class: classes["new-todo"],
          placeholder: 'What needs to be done?',
          value: $value
        },
        handler: {keypress, input},
      })
    });
  });
};

7.


, effector-dom list.


— , , . list($store, itemCallback) .


.


, todomvc-app-css - , . .


// src/view/main.js
import {h, spec, list} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {TodoItem} from './todoItem';
import {$filteredTodos, allCompleted} from '../model';

export const Main = () => {
  h('section', () => {
    spec({attr: {class: classes.main}});

    //   
    h('input', {
      attr: {id: 'toggle-all', class: classes['toggle-all'], type: 'checkbox'}
    });
    h('label', {attr: {for: 'toggle-all'}, handler: {click: allCompleted}});

    //  
    h('ul', () => {
      spec({attr: {class: classes["todo-list"]}});
      list({
        source: $filteredTodos,
        key: 'title',
        fields: ['title', 'completed']
        //  fields     
      }, ({fields: [title, completed], key}) => TodoItem({title, completed, key})); 
    });
  });
};

8.


, effector-dom - , ..


, toggled removed - — .


effector — event.prepend.


store.map


// src/view/todoItem.js
import {h, spec} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {toggled, removed} from '../model';

// title  completed -    
export const TodoItem = ({title, completed, key}) => {
  h('li', () => {
    //       
    spec({attr: {class: completed.map(flag => flag ? classes.completed : false)}});

    h('div', () => {
      spec({attr: {class: classes.view}});

      h('input', {
        attr: {class: classes.toggle, type: 'checkbox', checked: completed},
        //     
        handler: {click: toggled.prepend(() => key)},
      });

      h('label', {text: title});

      h('button', {
        attr: {class: classes.destroy},
        //     
        handler: {click: removed.prepend(() => key)},
      });
    });
  });
};

9.


,


// src/view/footer.js
import {h, spec} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {$todos, $activeFilter, filtered, completedRemoved} from '../model';

export const Footer = () => {
  h('footer', () => {
    spec({attr: {class: classes['footer']}});

    h('span', () => { //   
      spec({attr: {class: classes['todo-count']}});

      const $activeCount = $todos.map(
        todos => todos.filter(todo => !todo.completed).length
      );

      h('strong', {text: $activeCount});
      h('span', {text: $activeCount.map(count => count === 1
        ? ' item left'
        : ' items left'
      )});
    });

    h('ul', () => { //  ,  
      spec({attr: {class: classes.filters}});

      h('li', () => {
        h('a', {
          attr: {class: $activeFilter.map(active => active === null
            ? classes.selected
            : false
          )},
          text: 'All',
          handler: {click: filtered.prepend(() => null)},
        });
      });

      h('li', () => {
        h('a', {
          attr: {class: $activeFilter.map(completed => completed === false
            ? classes.selected
            : false
          )},
          text: 'Active',
          handler: {click: filtered.prepend(() => false)},  
        });
      });

      h('li', () => {
        h('a', {
          attr: {class: $activeFilter.map(completed => completed === true
            ? classes.selected
            : false
          )},
          text: 'Completed',
          handler: {click: filtered.prepend(() => true)},
        });
      });
    });

    h('button', {
      attr: {class: classes['clear-completed']},
      text: 'Clear completed',
      handler: {click: completedRemoved},
    });
  });
};

10.


Such a simple application turned out, it was specially made as simple as possible, with minimal separation into separate entities.


Of course, if desired, the same filter buttons can be taken out into a separate entity, which can be used to convey the filter type and name.


const FilterButton = ({filter, text}) => {
  h('li', () => {
    h('a', {
      attr: {class: $activeFilter.map(completed => completed === filter
        ? classes.selected
        : false
      )},
      text: text,
      handler: {click: filtered.prepend(() => filter)},  
    });
  });
};

h('ul', () => { 
  spec({attr: {class: classes.filters}});

  FilterButton({filter: null, text: 'All'});
  FilterButton({filter: false, text: 'Active'});
  FilterButton({filter: true, text: 'Completed'});
});

In the same way, thanks to its stack-based work, effector-dom allows you to freely render not only individual elements, but also general behavior.


The rendered code will be applied specifically to the necessary elements, for example:


const WithFocus = () => {
  const focus = createEvent();
  focus.watch(() => console.log('focused'));

  spec({handler: {focus}});
};

h('input', () => {
  ...
  WithFocus();
  ...
});

11. Total


I was personally impressed by the simplicity of working with the new render, especially since I already had experience working with effector in a large application.


I look forward to a stable version, some kind of popularization for the opportunity to use the render in the prod.

Source: https://habr.com/ru/post/undefined/


All Articles