Tutorial: SKDB + React.js¶
This tutorial aims to demonstrate some key features of SKDB by walking you through creating an interactive multi-user to-do list app using SKDB and React.js.
You can either follow along with the process here or jump to the
intermediate/end results in the branches of the todo-app
repository.
First, you will need to start a local SKDB server and clone/run the demo todo-app, as described in the Quick-Start Guide. Before proceeding, make sure you have a dev server running with
and the to-do app running with
Let's add a feature¶
Next, we'll add a new feature to the to-do list app: a "tags" system that allows users the ability to categorize and label tasks.
The skdb-react package provides React.js primitives that make it fast and easy to build reactive components backed by the synchronized database. You can either follow along with the process here or check out the tags-feature branch of the to-do list app to see the completed feature in action and the code required to implement it.
Modify the schema (server-side)¶
The server works with data described by a schema. In order to introduce tags, we'll first need to extend the database schema by adding the following commands to src/main.tsx
"CREATE TABLE tags (id TEXT PRIMARY KEY, name TEXT, skdb_access TEXT);",
"CREATE TABLE tasks_tags (task_id TEXT, tag_id TEXT, skdb_access TEXT);",
This adds a table for tags, adds a linking table to relate them to tasks. Note
the skdb_access
column in both tables, which SKDB will use to control read and
write permissions for different users as we will discuss below.
Mirror the data locally¶
With SKDB, each client has an embedded local database whose data is kept in sync with remote servers automatically. Each client chooses which data it needs locally by specifying table names and/or filters.
To implement tags, we'll "mirror" both new tables in full on the client by
adding them to the mirror
command in
src/main.tsx.
{
table: "tags",
expectedColumns: "(id TEXT PRIMARY KEY, name TEXT, skdb_access TEXT)",
},
{
table: "tasks_tags",
expectedColumns: "(task_id TEXT, tag_id TEXT, skdb_access TEXT)",
},
Create a React component¶
SKDB provides a useQuery
primitive that can be used to build React components
that are data-reactive: when the underlying data changes -- either locally or
due to synchronization with other clients -- the component will automatically
refresh.
The following code is all that is needed to display a list of all available
tags, and can be added to src/App.tsx
:
interface Tag {
id: string;
name: string;
}
function TagsList() {
const tags = useQuery("SELECT * FROM tags;") as Array<Tag>;
return (
<Box>
{tags.map((tag) => (
<TagItem tag={tag} key={tag.id} />
))}
</Box>
);
}
function TagDrawer({
mobileOpen,
handleDrawerToggle,
}: {
mobileOpen: boolean;
handleDrawerToggle: () => void;
}) {
const drawer = (
<div>
<AddTags />
<Divider />
<TagsList />
</div>
);
return (
<Box component="nav" className="tags">
<Drawer
variant="temporary"
className="tags-list temp-list"
open={mobileOpen}
onClose={handleDrawerToggle}
sx={{ position: "relative" }}
>
{drawer}
</Drawer>
<Drawer variant="permanent" className="tags-list perm-list" open>
{drawer}
</Drawer>
</Box>
);
}
Modify the state of the database¶
Modifying state with the database is also very easy: useSKDB
gives you
a handle on the local database which can be used to modify the data, for example
when a button is pressed.
Add the following to src/App.tsx
to implement components which
- display existing tags with a button to delete them,
function TagItem({ tag }: { tag: Tag }) { const skdb = useSKDB(); const del = async (tag: Tag) => { skdb.exec("DELETE FROM tags WHERE id = @id;", tag); skdb.exec("DELETE FROM tasks_tags WHERE tag_id = @id;", tag); }; return ( <Box className="tag"> <Typography noWrap component="div" sx={{ flex: 1 }}> {tag.name} </Typography> <IconButton title="Delete" onClick={(_e) => del(tag)}> <DeleteIcon /> </IconButton> </Box> ); }
- define and create new tags using a text field,
function AddTags() { const skdb = useSKDB(); const [tagName, setTagName] = useState(""); const isEmpty = useMemo(() => tagName.length == 0, [tagName]); const handleTagName = (e: any) => { setTagName(e.target.value); }; const addTag = async (name: string) => { if (isEmpty) { return; } skdb.exec( "INSERT INTO tags (name, skdb_access) VALUES (@name, 'read-write');", { name }, ); setTagName(""); }; // 13 is keycode for enter const onKeyDown = ({ keyCode }: { keyCode: number }) => { if (keyCode == 13) addTag(tagName); }; return ( <Box className="new"> <TextField placeholder="Enter the new tag name" label="Tag name" variant="standard" onChange={handleTagName} value={tagName} onKeyDown={onKeyDown} /> <IconButton disabled={isEmpty} title="Add Tag" onClick={(_e) => addTag(tagName)} > <AddIcon /> </IconButton> </Box> ); }
- display and select tags for a given task.
function TagDropdown({ task }: { task: Task }) { const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent<HTMLElement>) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const skdb = useSKDB(); const remainingTags = useQuery( "SELECT * FROM tags WHERE id NOT IN (SELECT tag_id FROM tasks_tags WHERE task_id = @id);", task, ); const handleChange = async (id: string) => { skdb.exec("INSERT INTO tasks_tags VALUES (@task, @tag, 'read-write');", { task: task.id, tag: id, }); handleClose(); }; return ( <div> <Button className="tagsdd" aria-haspopup="true" aria-expanded={open ? "true" : undefined} variant="outlined" disableElevation onClick={handleClick} startIcon={<TagIcon />} endIcon={<KeyboardArrowDownIcon />} disabled={remainingTags.length == 0} title="Tags" ></Button> <Menu anchorEl={anchorEl} open={open} onClose={handleClose}> {remainingTags.map((tag: Tag) => ( <MenuItem value={tag.id} key={tag.id} onClick={() => { handleChange(tag.id); }} > {tag.name} </MenuItem> ))} </Menu> </div> ); } function Tags({ task }: { task: Task }) { const skdb = useSKDB(); const selectedTags = useQuery( "SELECT * FROM tags WHERE id IN (SELECT tag_id FROM tasks_tags WHERE task_id = @id);", task, ); const del = (id: string) => { skdb.exec( "DELETE FROM tasks_tags WHERE task_id = @task_id AND tag_id = @tag_id;", { task_id: task.id, tag_id: id }, ); }; return ( <div className="tags"> {selectedTags.map((tag: Tag) => { const handleDelete = () => del(tag.id); return ( <Chip label={tag.name} key={tag.id} size="small" onDelete={handleDelete} /> ); })} </div> ); }
Show the component¶
First, make some space on the page to display tags to the left of tasks by
updating the styles for the :root
selector at the top of src/App.css
,
changing --drawer-width
from 0px
to 250px
and --gap
from 0px
to 8px
.
Then, display a list of available tags by adding the following to the App
function, just before the closing </div>
:
along with a button to toggle the display on narrow windows, as the first child
of the <Toolbar>
in the Header
function:
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: "none" } }}
>
<MenuIcon />
</IconButton>
Finally, show the list of tags for each task by adding a Tags
component as the
first child of the first TableCell
in TaskRow
.
TableCell
and the one containing the "Delete" button.
Now, the to-do list shows a list of tags attached to each task and a list of existing tags, along with controls to add or delete tags and categorize tasks using tags.
Note that we were able to express the application logic easily using standard SQL to read and write to our local database, but SKDB is still synchronizing application state across different browser instances in real-time.
Build something more complex¶
Up to this point, our app has allowed any user to read and write any data, but real applications require access control -- and SKDB makes it easy with fine-grained primitives for management of permissions and privacy.
To show this in action, let's add a "like" button to tasks in our to-do list app. Users should be able to like/un-like any task and see the total number of likes on each task, but they should not be able to see exactly who liked which task, like a post multiple times, or send a like as another user.
As before, you can either follow along here or check out the likes-feature branch of the to-do list app to see the completed feature in action and the code required to implement it.
First, we will enable SKDB's dev console, which lets you easily create
and switch between users during development. Replace SKDBProvider
by
SKDBDevConsoleProvider
in the imports and rendering function of
src/main.tsx.
<React.StrictMode>
- <SKDBProvider skdb={skdb}>
+ <SKDBDevConsoleProvider skdbAsRoot={skdb} create={connect}>
<App />
- </SKDBProvider>
+ </SKDBDevConsoleProvider>
</React.StrictMode>,
Now, you can create and switch between users in the todo-app by using the console widget in the top right corner.
Modify the schema¶
First, we'll add a likes
table to the database schema in
src/main.tsx:
Next, we'll create a reactive view on that table, counting the number of unique likes on each task. Since SKDB uses an incremental engine, this will be efficiently kept up-to-date as the underlying table's data changes.
`CREATE REACTIVE VIEW unique_likes AS
SELECT task_id, COUNT(*) as n FROM
( SELECT task_id, skdb_author FROM likes GROUP BY task_id, skdb_author )
GROUP BY task_id;`,
Mirror both tables to the client, adding the following arguments to your call to
localDb.mirror
:
{
table: "likes",
expectedColumns: "(task_id TEXT, skdb_author TEXT, skdb_access TEXT)",
},
{ table: "unique_likes", expectedColumns: "(task_id TEXT, n INTEGER)" },
Add a Like button component¶
Now, we can define the following React component, which will display a Like button for a task, which is styled as either liked or unliked by the current user, displays a count of total users that have liked the given task, and can be clicked repeatedly to add/remove the current user's "like".
The logic is simple and straightforward because we're using SKDB's powerful
permissions model to ensure that users can only see their own likes by setting
the skdb_access
field of each like to the current user uid
when we insert
them in the onClick
callback.
function Like(task: { taskId: string }) {
const skdb = useSKDB();
let currUserLikes = useQuery("SELECT 1 FROM likes WHERE task_id = @taskId", {
taskId: task.taskId,
});
const onClick = async () => {
const userID = skdb.currentUser;
if (currUserLikes.length == 0) {
skdb.exec("INSERT INTO likes VALUES (@taskId, @userID, @userID);", {
taskId: task.taskId,
userID,
});
} else {
skdb.exec("DELETE FROM likes WHERE task_id = @taskId", {
taskId: task.taskId,
});
}
};
let unique_likes = useQuery(
"SELECT n FROM unique_likes WHERE task_id = @taskId",
{ taskId: task.taskId },
);
let like_count = unique_likes.length == 1 ? unique_likes[0].n : 0;
return (
<IconButton onClick={(_e) => onClick()}>
<Badge badgeContent={like_count} color="success">
{currUserLikes.length == 0 ? <HeartIcon /> : <FullHeartIcon />}
</Badge>
</IconButton>
);
}
Finally, add the Like
button to each TaskRow
, just after the tag dropdown
TableCell
:
See permissions in action¶
Now, open the to-do list in two (or more) browser windows and use the SKDB developer console to create multiple users.
Playing around with the application by adding tasks and adding/removing likes, you should see the expected behaviors described at the start of this section: users can see their own likes and a count of total likes, but should neither be able to see nor modify other users' likes.
Note also that a user's state is persisted if you close and re-open the app as that user, and synchronized between multiple active instances with the same user.
Privacy and permissions¶
To achieve our desired privacy semantics for likes, we used some basic components of SKDB's permissions model to ensure that a user's likes can only be created or seen by that user.
This one-size-fits-all policy is easy to define, but what of more complex policies granting users varying degrees of privilege? SKDB has a powerful and fine-grained system for defining and administering privileges, which we will demonstrate in the to-do app by implementing user-controlled privacy on both tasks and tags.
As before, you can either follow along here or check out the access-control-feature branch of the to-do list app to see the completed feature in action and the code required to implement it.
Add a group-creation function¶
In order to restrict access to arbitrary subsets of users, we'll need to create SKDB groups.
First, let's add the following reactive view to our database schema in
src/main.tsx
, to make the list of users available for (read-only)
mirroring.
Next, we'll mirror that list of users
, excluding the root
user with a
filterExpr
which is interpreted analogously to a SQL WHERE
clause and can
be used to specify a subset of a table that you're interested in.
Group and permission-related tables (
skdb_groups
,skdb_group_permissions
, andskdb_user_permissions
) are automatically mirrored in order to enable client-side privacy checks, so don't need to be included here.
Now, let's define a createGroup
utility function, which we'll use to create a
group owned by the current user, granting some permissions (read + write by
default) to a list of members
async function createGroup(
skdb: SKDB,
members: string[],
perm: string = "rw",
): Promise<string> {
const userID = skdb.currentUser;
const groupID = (
await skdb.exec(
`BEGIN TRANSACTION;
INSERT INTO skdb_groups VALUES (id('groupID'), @userID, @userID, @userID);
SELECT id('groupID') AS groupID;
COMMIT;`,
{ userID },
)
).scalarValue();
skdb.exec(
"INSERT INTO skdb_group_permissions VALUES (@groupID, @userID, skdb_permission('rw'), @userID)",
{ groupID, userID },
);
skdb.exec(
"UPDATE skdb_groups SET skdb_access = @groupID WHERE groupID = @groupID;",
{ groupID },
);
for (const member of members) {
skdb.exec(
"INSERT INTO skdb_group_permissions VALUES (@groupID, @member, skdb_permission(@perm), @groupID)",
{ groupID, member, perm },
);
}
return groupID;
}
The first INSERT
(into skdb_groups
) creates the group, specifying the
current user as the exclusive owner/administrator; the second INSERT
(into
skdb_group_permissions
) grants them read and write privileges on data whose
access is restricted to the group. Then, for each member
, we grant the
specified permissions (read and write, by default) on said data.
Add an access-control React component¶
Next, we'll build a dropdown menu that can be used to select a subset
accessList
of other SKDB users.
interface User {
userID: string;
}
function VisibilityDropdown({
accessList,
setAccessList,
}: {
accessList: string[];
setAccessList: (l: string[]) => void;
}) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const skdb = useSKDB();
const otherUsers = useQuery("SELECT * FROM users WHERE userID <> @userID;", {
userID: skdb.currentUser,
});
const handleChange = async (uuid: string) => {
if (accessList.includes(uuid)) {
setAccessList(accessList.filter((e) => e !== uuid));
} else {
accessList.push(uuid);
setAccessList(accessList);
}
handleClose();
};
return (
<div>
<Badge
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
badgeContent={otherUsers.length}
color="primary"
>
<Badge badgeContent={accessList.length} color="success">
<Button
className="visibility"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
variant="outlined"
disableElevation
onClick={handleClick}
startIcon={<VisibilityIcon />}
endIcon={<KeyboardArrowDownIcon />}
disabled={otherUsers.length == 0}
title="Visibility"
></Button>
</Badge>
</Badge>
<Menu
className="users"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
{otherUsers.map((user: User) => {
return (
<MenuItem
value={user.userID}
key={user.userID}
onClick={() => {
handleChange(user.userID);
}}
>
<Checkbox checked={accessList.includes(user.userID)} />
{user.userID}
</MenuItem>
);
})}
</Menu>
</div>
);
}
Integrate with Tag and Task creation¶
Now, we'll add the dropdown menu to our existing AddTasks
component, allowing
users to select an arbitrary subset of users who can see each task that they
create by creating a group and setting the skdb_access
of the created tasks to
that group's ID.
function AddTasks() {
const skdb = useSKDB();
const [taskName, setTaskName] = useState("");
+ const [accessList, setAccessList] = useState([]);
// ...
const addTask = async (name: string) => {
if (isEmpty) {
return;
}
+ const access_group = await createGroup(skdb, accessList);
skdb.exec(
- "INSERT INTO tasks (name, complete, skdb_access) VALUES (@name, 0, 'read-write');",
- { name },
+ "INSERT INTO tasks (name, complete, skdb_access) VALUES (@name, 0, @access_group);",
+ { name, access_group },
);
setTaskName("");
};
// ...
return (
// ...
onKeyDown={onKeyDown}
/>
+ <VisibilityDropdown {...{ accessList, setAccessList }} />
<IconButton
disabled={isEmpty}
// ...
);
}
AddTags
:
function AddTags() {
const skdb = useSKDB();
const [tagName, setTagName] = useState("");
+ const [accessList, setAccessList] = useState([]);
const addTag = async (name: string) => {
if (isEmpty) {
return;
}
+ const access_group = await createGroup(skdb, accessList);
skdb.exec(
- "INSERT INTO tags (name, skdb_access) VALUES (@name, 'read-write');",
- { name },
+ "INSERT INTO tags (name, skdb_access) VALUES (@name, @access_group);",
+ { name, access_group },
);
setTagName("");
};
// ...
return (
// ...
onKeyDown={onKeyDown}
/>
+ <VisibilityDropdown {...{ accessList, setAccessList }} />
<IconButton
disabled={isEmpty}
// ...
);
}
See privacy in action¶
Now, open the to-do list in two (or more) browser windows again, and use the developer console to connect as multiple users.
Try creating tasks and tags with varying degrees of visibility to your other users, tagging or liking tasks, and deleting tasks or tags. Each user will see snappy reactive updates, but with data and aggregations matching exactly what your permissions model dictates they should be able to access.
Conclusion¶
This to-do app is of course just a small example, but it should give you a sense of what is possible with SKDB -- and just how easy it makes it to build reactive applications and interactive user experiences.
We are excited to see what you build with it!