Skip to content

Instantly share code, notes, and snippets.

@ryansolid
Last active February 17, 2024 14:29
Show Gist options
  • Save ryansolid/aa5bd12ed4e2f9d592c4b23e58d6fa85 to your computer and use it in GitHub Desktop.
Save ryansolid/aa5bd12ed4e2f9d592c4b23e58d6fa85 to your computer and use it in GitHub Desktop.
Looking at how frameworks scale using their official TodoMVC Example and Vite

Based on Evan You's comparison which included Svelte and Vue. https://github.com/yyx990803/vue-svelte-size-analysis

For Preact, React, and Solid I took their official TodoMVC and ran them through Vite (2.3.6) to get vendor. For the components I grabbed the unminified source, ran it through Terser Repl, removed the imports, and ran through Brotli.

I wanted to use hooks for React and Preact since those are much smaller but all the official demos use classes. Adding Hooks adds library weight to Preact but it is worth it for this comparison(Preact with classes was 1.60kb and 3.81kb). Honestly I couldn't find a good hooks implementation for React/Preact that was small so I made one myself based on Solid.

Preact React Solid Svelte Vue
component size (brotli) 1.21kb 1.23kb 1.26kb 1.88kb 1.10kb
vendor size (brotli) 4.39kb 36.22kb 3.86kb 1.85kb 16.89kb

So putting the inflection points in a table. It reads how many TodoMVCs before the row catches up to the column in size.

Svelte Solid Preact Vue React
Svelte - 3.2 3.8 19.3 52.9
Solid - - 10.6 81.4 1078.7
Preact - - - 113.6 -
Vue - - - - -
React - - - - -

Size per N TodoMVC Components + Vendor Chunk

1 5 10 20 40 80
Svelte 3.73kb 11.25kb 20.65kb 39.45kb 77.05kb 152.25kb
Solid 5.12kb 10.16kb 16.46kb 29.06kb 54.26kb 104.66kb
Preact 5.60kb 10.44kb 16.49kb 28.59kb 52.79kb 101.19kb
Vue 17.99kb 22.39kb 27.89kb 38.89kb 60.89kb 104.89kb
React 37.45kb 42.37kb 48.52kb 60.82kb 85.42kb 134.62kb

TodoMVC Components at each size budget

React Vue Preact Solid Svelte
10kb - - 4.6 4.7 4.3
20kb - 2.8 12.9 12.4 9.7
40kb 3.1 21 29.4 28.7 20.3
70kb 27.5 48.3 54.2 52.5 36.3
100kb 51.9 75.6 79.0 76.3 52.2
import { render, h } from "preact";
import { useState, useEffect, useMemo } from "preact/hooks";
const ESCAPE_KEY = 27;
const ENTER_KEY = 13;
const setFocus = (el) => setTimeout(() => el.focus());
const LOCAL_STORAGE_KEY = "todos-solid";
function useLocalStore(value) {
// load stored todos on init
const stored = localStorage.getItem(LOCAL_STORAGE_KEY),
[store, setStore] = useState(stored ? JSON.parse(stored) : value);
// JSON.stringify creates deps on every iterable field
useEffect(
() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(store)),
[store]
);
return [store, setStore];
}
const TodoItem = ({
todo,
store,
toggle,
setEditing,
removeTodo,
save,
doneEditing,
}) => {
return (
<li
class="todo"
classList={{
editing: store.editingTodoId === todo.id,
completed: todo.completed,
}}
>
<div class="view">
<input
class="toggle"
type="checkbox"
checked={todo.completed}
onInput={(e) => toggle(todo.id, e)}
/>
<label onDblClick={() => setEditing(todo.id)}>{todo.title}</label>
<button class="destroy" onClick={() => removeTodo(todo.id)} />
</div>
{store.editingTodoId === todo.id && (
<input
class="edit"
value={todo.title}
onFocusOut={() => save(todo.id)}
onKeyUp={() => doneEditing(todo.id)}
ref={setFocus}
/>
)}
</li>
);
};
const TodoApp = () => {
const [store, setStore] = useLocalStore({
counter: 1,
todos: [],
showMode: "all",
editingTodoId: null,
}),
remainingCount = useMemo(
() =>
store.todos.length - store.todos.filter((todo) => todo.completed).length
),
filterList = (todos) => {
if (store.showMode === "active")
return todos.filter((todo) => !todo.completed);
else if (store.showMode === "completed")
return todos.filter((todo) => todo.completed);
else return todos;
},
removeTodo = (todoId) =>
setStore((s) => ({
...s,
todos: s.todos.filter((item) => item.id !== todoId),
})),
editTodo = (todo) =>
setStore((s) => ({
...s,
todos: s.todos.map((item) => {
if (item.id !== todo.id) return item;
return { ...item, ...todo };
}),
})),
clearCompleted = () =>
setStore((s) => ({
...s,
todos: s.todos.filter((todo) => !todo.completed),
})),
toggleAll = (completed) =>
setStore((s) => ({
...s,
todos: s.todos.map((todo) => {
if (todo.completed === completed) return todo;
return { ...todo, completed };
}),
})),
setEditing = (todoId) => setStore((s) => ({ ...s, editingTodoId: todoId })),
addTodo = ({ target, keyCode }) => {
const title = target.value.trim();
if (keyCode === ENTER_KEY && title) {
setStore((s) => ({
...s,
todos: [
{ title, id: store.counter, completed: false },
...store.todos,
],
counter: store.counter + 1,
}));
target.value = "";
}
},
save = (todoId, { target: { value } }) => {
const title = value.trim();
if (store.editingTodoId === todoId && title) {
editTodo({ id: todoId, title });
setEditing();
}
},
toggle = (todoId, { target: { checked } }) =>
editTodo({ id: todoId, completed: checked }),
doneEditing = (todoId, e) => {
if (e.keyCode === ENTER_KEY) save(todoId, e);
else if (e.keyCode === ESCAPE_KEY) setEditing();
};
const locationHandler = () =>
setStore((s) => ({ ...s, showMode: location.hash.slice(2) || "all" }));
useEffect(() => {
window.addEventListener("hashchange", locationHandler);
return () => window.removeEventListener("hashchange", locationHandler);
});
return (
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
onKeyDown={addTodo}
/>
</header>
{store.todos.length > 0 && (
<>
<section class="main">
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
checked={!remainingCount}
onInput={({ target: { checked } }) => toggleAll(checked)}
/>
<label for="toggle-all" />
<ul class="todo-list">
{filterList(store.todos).map((todo) => (
<TodoItem
{...{
todo,
store,
toggle,
setEditing,
removeTodo,
save,
doneEditing,
}}
/>
))}
</ul>
</section>
<footer class="footer">
<span class="todo-count">
<strong>{remainingCount}</strong>{" "}
{remainingCount === 1 ? " item " : " items "} left
</span>
<ul class="filters">
<li>
<a href="#/" classList={{ selected: store.showMode === "all" }}>
All
</a>
</li>
<li>
<a
href="#/active"
classList={{ selected: store.showMode === "active" }}
>
Active
</a>
</li>
<li>
<a
href="#/completed"
classList={{ selected: store.showMode === "completed" }}
>
Completed
</a>
</li>
</ul>
{remainingCount !== store.todos.length && (
<button class="clear-completed" onClick={clearCompleted}>
Clear completed
</button>
)}
</footer>
</>
)}
</section>
);
};
render(<TodoApp />, document.getElementById("app"));
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
const ESCAPE_KEY = 27;
const ENTER_KEY = 13;
const setFocus = (el) => setTimeout(() => el.focus());
const LOCAL_STORAGE_KEY = "todos-solid";
function useLocalStore(value) {
// load stored todos on init
const stored = localStorage.getItem(LOCAL_STORAGE_KEY),
[store, setStore] = useState(stored ? JSON.parse(stored) : value);
// JSON.stringify creates deps on every iterable field
useEffect(
() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(store)),
[store]
);
return [store, setStore];
}
const TodoItem = ({
todo,
store,
toggle,
setEditing,
removeTodo,
save,
doneEditing,
}) => {
return (
<li
class="todo"
classList={{
editing: store.editingTodoId === todo.id,
completed: todo.completed,
}}
>
<div class="view">
<input
class="toggle"
type="checkbox"
checked={todo.completed}
onInput={(e) => toggle(todo.id, e)}
/>
<label onDblClick={() => setEditing(todo.id)}>{todo.title}</label>
<button class="destroy" onClick={() => removeTodo(todo.id)} />
</div>
{store.editingTodoId === todo.id && (
<input
class="edit"
value={todo.title}
onFocusOut={() => save(todo.id)}
onKeyUp={() => doneEditing(todo.id)}
ref={setFocus}
/>
)}
</li>
);
};
const TodoApp = () => {
const [store, setStore] = useLocalStore({
counter: 1,
todos: [],
showMode: "all",
editingTodoId: null,
}),
remainingCount = useMemo(
() =>
store.todos.length - store.todos.filter((todo) => todo.completed).length
),
filterList = (todos) => {
if (store.showMode === "active")
return todos.filter((todo) => !todo.completed);
else if (store.showMode === "completed")
return todos.filter((todo) => todo.completed);
else return todos;
},
removeTodo = (todoId) =>
setStore((s) => ({
...s,
todos: s.todos.filter((item) => item.id !== todoId),
})),
editTodo = (todo) =>
setStore((s) => ({
...s,
todos: s.todos.map((item) => {
if (item.id !== todo.id) return item;
return { ...item, ...todo };
}),
})),
clearCompleted = () =>
setStore((s) => ({
...s,
todos: s.todos.filter((todo) => !todo.completed),
})),
toggleAll = (completed) =>
setStore((s) => ({
...s,
todos: s.todos.map((todo) => {
if (todo.completed === completed) return todo;
return { ...todo, completed };
}),
})),
setEditing = (todoId) => setStore((s) => ({ ...s, editingTodoId: todoId })),
addTodo = ({ target, keyCode }) => {
const title = target.value.trim();
if (keyCode === ENTER_KEY && title) {
setStore((s) => ({
...s,
todos: [
{ title, id: store.counter, completed: false },
...store.todos,
],
counter: store.counter + 1,
}));
target.value = "";
}
},
save = (todoId, { target: { value } }) => {
const title = value.trim();
if (store.editingTodoId === todoId && title) {
editTodo({ id: todoId, title });
setEditing();
}
},
toggle = (todoId, { target: { checked } }) =>
editTodo({ id: todoId, completed: checked }),
doneEditing = (todoId, e) => {
if (e.keyCode === ENTER_KEY) save(todoId, e);
else if (e.keyCode === ESCAPE_KEY) setEditing();
};
const locationHandler = () =>
setStore((s) => ({ ...s, showMode: location.hash.slice(2) || "all" }));
useEffect(() => {
window.addEventListener("hashchange", locationHandler);
return () => window.removeEventListener("hashchange", locationHandler);
});
return (
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
onKeyDown={addTodo}
/>
</header>
{store.todos.length > 0 && (
<>
<section class="main">
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
checked={!remainingCount}
onInput={({ target: { checked } }) => toggleAll(checked)}
/>
<label for="toggle-all" />
<ul class="todo-list">
{filterList(store.todos).map((todo) => (
<TodoItem
{...{
todo,
store,
toggle,
setEditing,
removeTodo,
save,
doneEditing,
}}
/>
))}
</ul>
</section>
<footer class="footer">
<span class="todo-count">
<strong>{remainingCount}</strong>{" "}
{remainingCount === 1 ? " item " : " items "} left
</span>
<ul class="filters">
<li>
<a href="#/" classList={{ selected: store.showMode === "all" }}>
All
</a>
</li>
<li>
<a
href="#/active"
classList={{ selected: store.showMode === "active" }}
>
Active
</a>
</li>
<li>
<a
href="#/completed"
classList={{ selected: store.showMode === "completed" }}
>
Completed
</a>
</li>
</ul>
{remainingCount !== store.todos.length && (
<button class="clear-completed" onClick={clearCompleted}>
Clear completed
</button>
)}
</footer>
</>
)}
</section>
);
};
ReactDOM.render(<TodoApp />, document.getElementById("root"));
import { createMemo, createEffect, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
import { render } from "solid-js/web";
const ESCAPE_KEY = 27;
const ENTER_KEY = 13;
const setFocus = (el) => setTimeout(() => el.focus());
const LOCAL_STORAGE_KEY = "todos-solid";
function createLocalStore(value) {
// load stored todos on init
const stored = localStorage.getItem(LOCAL_STORAGE_KEY),
[state, setState] = createStore(stored ? JSON.parse(stored) : value);
// JSON.stringify creates deps on every iterable field
createEffect(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)));
return [state, setState];
}
const TodoApp = () => {
const [state, setState] = createLocalStore({
counter: 1,
todos: [],
showMode: "all",
editingTodoId: null,
}),
remainingCount = createMemo(() => state.todos.length - state.todos.filter((todo) => todo.completed).length),
filterList = (todos) => {
if (state.showMode === "active") return todos.filter((todo) => !todo.completed);
else if (state.showMode === "completed") return todos.filter((todo) => todo.completed);
else return todos;
},
removeTodo = (todoId) => setState("todos", (t) => t.filter((item) => item.id !== todoId)),
editTodo = (todo) => setState("todos", (item) => item.id === todo.id, todo),
clearCompleted = () => setState("todos", (t) => t.filter((todo) => !todo.completed)),
toggleAll = (completed) => setState("todos", (todo) => todo.completed !== completed, { completed }),
setEditing = (todoId) => setState("editingTodoId", todoId),
addTodo = ({ target, keyCode }) => {
const title = target.value.trim();
if (keyCode === ENTER_KEY && title) {
setState({
todos: [{ title, id: state.counter, completed: false }, ...state.todos],
counter: state.counter + 1
});
target.value = "";
}
},
save = (todoId, { target: { value } }) => {
const title = value.trim();
if (state.editingTodoId === todoId && title) {
editTodo({ id: todoId, title });
setEditing();
}
},
toggle = (todoId, { target: { checked } }) => editTodo({ id: todoId, completed: checked }),
doneEditing = (todoId, e) => {
if (e.keyCode === ENTER_KEY) save(todoId, e);
else if (e.keyCode === ESCAPE_KEY) setEditing();
};
const locationHandler = () => setState("showMode", location.hash.slice(2) || "all");
window.addEventListener("hashchange", locationHandler);
onCleanup(() => window.removeEventListener("hashchange", locationHandler));
return (
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input class="new-todo" placeholder="What needs to be done?" onKeyDown={addTodo} />
</header>
<Show when={state.todos.length > 0}>
<section class="main">
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
checked={!remainingCount()}
onInput={({ target: { checked } }) => toggleAll(checked)}
/>
<label for="toggle-all" />
<ul class="todo-list">
<For each={filterList(state.todos)}>
{(todo) => (
<li
class="todo"
classList={{ editing: state.editingTodoId === todo.id, completed: todo.completed }}
>
<div class="view">
<input class="toggle" type="checkbox" checked={todo.completed} onInput={[toggle, todo.id]}/>
<label onDblClick={[setEditing, todo.id]}>{todo.title}</label>
<button class="destroy" onClick={[removeTodo, todo.id]} />
</div>
<Show when={state.editingTodoId === todo.id}>
<input
class="edit"
value={todo.title}
onFocusOut={[save, todo.id]}
onKeyUp={[doneEditing, todo.id]}
use:setFocus
/>
</Show>
</li>
)}
</For>
</ul>
</section>
<footer class="footer">
<span class="todo-count">
<strong>{remainingCount()}</strong>{" "}
{remainingCount() === 1 ? " item " : " items "} left
</span>
<ul class="filters">
<li><a href="#/" classList={{selected: state.showMode === "all"}}>All</a></li>
<li><a href="#/active" classList={{selected: state.showMode === "active"}}>Active</a></li>
<li><a href="#/completed" classList={{selected: state.showMode === "completed"}}>Completed</a></li>
</ul>
<Show when={remainingCount() !== state.todos.length}>
<button class="clear-completed" onClick={clearCompleted}>
Clear completed
</button>
</Show>
</footer>
</Show>
</section>
);
};
render(TodoApp, document.getElementById("main"));
<script>
const ENTER_KEY = 13;
const ESCAPE_KEY = 27;
let currentFilter = 'all';
let items = [];
let editing = null;
try {
items = JSON.parse(localStorage.getItem('todos-svelte')) || [];
} catch (err) {
items = [];
}
const updateView = () => {
currentFilter = 'all';
if (window.location.hash === '#/active') {
currentFilter = 'active';
} else if (window.location.hash === '#/completed') {
currentFilter = 'completed';
}
};
window.addEventListener('hashchange', updateView);
updateView();
function clearCompleted() {
items = items.filter(item => !item.completed);
}
function remove(index) {
items = items.slice(0, index).concat(items.slice(index + 1));
}
function toggleAll(event) {
items = items.map(item => ({
id: item.id,
description: item.description,
completed: event.target.checked
}));
}
function createNew(event) {
if (event.which === ENTER_KEY) {
items = items.concat({
id: Date.now(),
description: event.target.value,
completed: false
});
event.target.value = '';
}
}
function handleEdit(event) {
if (event.which === ENTER_KEY) event.target.blur();
else if (event.which === ESCAPE_KEY) editing = null;
}
function submit(event) {
items[editing].description = event.target.value;
editing = null;
}
$: filtered = currentFilter === 'all'
? items
: currentFilter === 'completed'
? items.filter(item => item.completed)
: items.filter(item => !item.completed);
$: numActive = items.filter(item => !item.completed).length;
$: numCompleted = items.filter(item => item.completed).length;
$: try {
localStorage.setItem('todos-svelte', JSON.stringify(items));
} catch (err) {
// noop
}
</script>
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
on:keydown={createNew}
placeholder="What needs to be done?"
autofocus
>
</header>
{#if items.length > 0}
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" on:change={toggleAll} checked="{numCompleted === items.length}">
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
{#each filtered as item, index (item.id)}
<li class="{item.completed ? 'completed' : ''} {editing === index ? 'editing' : ''}">
<div class="view">
<input class="toggle" type="checkbox" bind:checked={item.completed}>
<label on:dblclick="{() => editing = index}">{item.description}</label>
<button on:click="{() => remove(index)}" class="destroy"></button>
</div>
{#if editing === index}
<input
value='{item.description}'
id="edit"
class="edit"
on:keydown={handleEdit}
on:blur={submit}
autofocus
>
{/if}
</li>
{/each}
</ul>
<footer class="footer">
<span class="todo-count">
<strong>{numActive}</strong> {numActive === 1 ? 'item' : 'items'} left
</span>
<ul class="filters">
<li><a class="{currentFilter === 'all' ? 'selected' : ''}" href="#/">All</a></li>
<li><a class="{currentFilter === 'active' ? 'selected' : ''}" href="#/active">Active</a></li>
<li><a class="{currentFilter === 'completed' ? 'selected' : ''}" href="#/completed">Completed</a></li>
</ul>
{#if numCompleted}
<button class="clear-completed" on:click={clearCompleted}>
Clear completed
</button>
{/if}
</footer>
</section>
{/if}
<script setup>
import { ref, computed, watchEffect } from 'vue'
const STORAGE_KEY = 'todos-petite-vue'
const filters = {
all: (todos) => todos,
active: (todos) => todos.filter((todo) => !todo.completed),
completed: (todos) => todos.filter((todo) => todo.completed)
}
const todos = ref(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'))
const visibility = ref('all')
const editedTodo = ref()
const filteredTodos = computed(() => filters[visibility.value](todos.value))
const remaining = computed(() => filters.active(todos.value).length)
function toggleAll(e) {
todos.value.forEach((todo) => (todo.completed = e.target.checked))
}
function addTodo(e) {
const value = e.target.value.trim()
if (value) {
todos.value.push({
id: Date.now(),
title: value,
completed: false
})
e.target.value = ''
}
}
function removeTodo(todo) {
todos.value.splice(todos.value.indexOf(todo), 1)
}
let beforeEditCache = ''
function editTodo(todo) {
beforeEditCache = todo.title
editedTodo.value = todo
}
function cancelEdit(todo) {
editedTodo.value = null
todo.title = beforeEditCache
}
function doneEdit(todo) {
if (editedTodo.value) {
editedTodo.value = null
todo.title = todo.title.trim()
if (!todo.title) removeTodo(todo)
}
}
function removeCompleted() {
todos.value = filters.active(todos.value)
}
watchEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.value))
})
function onHashChange() {
const route = window.location.hash.replace(/#\/?/, '')
if (filters[route]) {
visibility.value = route
} else {
window.location.hash = ''
visibility.value = 'all'
}
}
window.addEventListener('hashchange', onHashChange)
onHashChange()
</script>
<template>
<div id="app">
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
autofocus
placeholder="What needs to be done?"
@keyup.enter="addTodo"
/>
</header>
<section class="main" v-show="todos.length">
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
:checked="remaining === 0"
@change="toggleAll"
/>
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
class="todo"
:key="todo.id"
:class="{ completed: todo.completed, editing: todo === editedTodo }"
>
<div class="view">
<input class="toggle" type="checkbox" v-model="todo.completed" />
<label @dblclick="editTodo(todo)">{{ todo.title }}</label>
<button class="destroy" @click="removeTodo(todo)"></button>
</div>
<input
v-if="todo === editedTodo"
class="edit"
type="text"
v-model="todo.title"
@vnode-mounted="({ el }) => el.focus()"
@blur="doneEdit(todo)"
@keyup.enter="doneEdit(todo)"
@keyup.escape="cancelEdit(todo)"
/>
</li>
</ul>
</section>
<footer class="footer" v-show="todos.length">
<span class="todo-count">
<strong>{{ remaining }}</strong>
<span>{{ remaining === 1 ? 'item' : 'items' }} left</span>
</span>
<ul class="filters">
<li>
<a href="#/all" :class="{ selected: visibility === 'all' }">All</a>
</li>
<li>
<a href="#/active" :class="{ selected: visibility === 'active' }">Active</a>
</li>
<li>
<a href="#/completed" :class="{ selected: visibility === 'completed' }">Completed</a>
</li>
</ul>
<button class="clear-completed" @click="removeCompleted" v-show="todos.length > remaining">
Clear completed
</button>
</footer>
</section>
</div>
</template>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment