Electron is one of the most popular frameworks for building cross-platform desktop applications. It's a great way to build desktop applications that can run on Windows, Mac, and Linux with a single codebase. It might not be totally obvious, but one of Electron's major benefits is that it's built on top of Chromium, which means that you can use all of the web technologies that you're already familiar with. This includes HTML, CSS, and JavaScript. In addition, you can write native code using the bundled NodeJS runtime. Why is this so special for Ditto? Well if you run Ditto inside of a Web Browser, we immediately use the WebAssembly build of our system. However, Web Browsers don't have any ability to leverage Ditto's Peer-to-Peer connectivity like Local Area Network, Apple Wireless Direct, or Bluetooth Low Energy. Put plainly, the Web Browser version of Ditto can only talk to the Big Peer (Ditto's cloud peer).
However, when Ditto runs in Electron or NodeJS, it has access to APIs that the web browser doesn't provide: such as access to the local file system, the ability to run native code, and advanced networking capabilities such as Local Area Network, Bluetooth Low Energy, and Apple Wireless Direct Link. This means, if you build an application using Ditto and Electron, you get the best of both worlds: cutting edge web development along with the novel capabilities of Ditto's peer-to-peer technology. Deploying a Ditto+Electron application means that your users will be able to have Mac, Windows, Linux (even Raspberry Pi) apps talk to each other without the need for a server.
Usually, Electron and Ditto alone don't make for a comprehensive development experience. You'll need to add a few more tools to the mix. In this tutorial, we'll show you how to quickly get started with some of the most popular enterprise tools and frameworks such as Ditto, Electron, React, TypeScript and Vite. Your project will end up looking like this:
Quick Start
1. Ensure that you have at least Node Version 18 or higher installed. You can check your version by running `node -v` in your terminal. If you need to update, you can do so by following the instructions here.
2. Run `npx electron-vite` in your terminal. This will create a new Electron app in the current directory. A series of prompts will appear. For the purposes of this tutorial, select:
✔ Project name: electron-react-tailwind-vite-ditto-sample
✔ Project template: React
3. Now `cd electron-react-tailwind-vite-ditto-sample` to enter the new directory. If you chose a different name for the project, replace `electron-`react-tailwind-vite-ditto-sample` with the name you chose.
4. Using your favorite editor, open the directory and you should see several files and folders. You'll need to begin by installing the dependencies. So run:
npm install
Install the Ditto SDK by running the following install scripts in your terminal.
npm install @dittolive/ditto @dittolive/react-ditto
This will install the Ditto SDK and the Ditto React SDK. Generally in this project you'll only need the Ditto React SDK, but we'll install both for the sake of completeness.
6. To force `@dittolive/ditto` to use the NodeJS build, add the following to your `vite.config.ts` file:
//...
- renderer
+ renderer({
+ resolve: {
+ '@dittolive/ditto': () => ({ platform: 'node' })
+ }
+}),
7. Before we continue, create a new application at [https://portal.ditto.live]. For this example, you'll need to create a new application with `OnlinePlayground`. You will need:
- The AppID, which you can copy from the "Big Peer Information Section"
- The OnlinePlayground Token which can be found in the "Authentication Mode & Webhook Settings" section. Ensure that the "playground" option is selected, and then copy the token.
8. Back in your editor, open `src/App.tsx` and add the following code to the top of the file:
import { DittoProvider } from "@dittolive/react-ditto"
import { Ditto } from "@dittolive/ditto"
const createDittoInstance = () => {
const ditto = new Ditto({
type: "onlinePlayground",
token: "REPLACE_ME",
appID: "REPLACE_ME",
})
ditto.startSync()
return ditto
}
function App() {
return (
<DittoProvider setup={createDittoInstance}>
{({ loading, error }) => {
if (loading) return <span>Loading Ditto...</span>
if (error)
return (
<span>
There was an error loading Ditto. Error: {error.toString()}
</span>
)
return <span>Ditto was successfully loaded!</span>
}}
</DittoProvider>
)
}
Now run:
npm run dev
If everything was successfully setup, then you should see:
At this point, you've successfully set up Ditto, TypeScript, and Electron. You can choose to stop this tutorial here and go on if you know what you're doing. However, if you'd like to stick around, I can walk you through adding Tailwind CSS so we can get started building a simple application.
Going Further
If you've decided to stay along for the ride, we're now going to add Ditto's favorite styling framework and create a simple To Do List application that uses Ditto's React SDK.
Bonus! Using Tailwind.
1. At Ditto, we love using Tailwind CSS to style our applications. To introduce Tailwind in this project, follow the instructions.
npm install -D tailwindcss postcss autoprefixer
2. Then create a bootstrapped tailwind.config.js file in the root of your project by running:
npx tailwindcss init -p
3. Open this file and edit tailwind.config.js the following:
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
}
4. Now create a file called `src/index.css` and add the following:
@tailwind base;
@tailwind components;
@tailwind utilities;
5. If you want to test that Tailwind is working, open `src/App.tsx` replace the following:
return <span>Ditto was successfully loaded!</span>
With:
return (
<h1 className="text-3xl font-bold underline text-blue-500">
Ditto was successfully loaded with tailwind!
</h1>
)
6. Now `run npm run dev` and you should see:
7. Since we now are using tailwind, we can remove the `src/index.scss` and `src/App.scss` file.
Building the Tasks functionality
1. We'll need the following dependencies to style Tailwind forms nicely. In addition we'll add an extremely popular tool to handle clasname generation. Run the following to install the dependencies:
npm install -D @tailwindcss/forms classnames
2. Add the following to `tailwind.config.js` to enable the `'@tailwindcss/forms'` plugin:
module.exports = {
// ...
plugins: [require("@tailwindcss/forms")],
}
3. Create a new file called `src/DataService.ts` and add the code below. This is our simple service layer that will handle all of our interactions with Ditto. The primary data model is called `Task` and we'll be using the `tasks` collection.
import { useMutations, usePendingCursorOperation } from "@dittolive/react-ditto"
export interface Task {
_id: string
body: string
isCompleted: boolean
}
const COLLECTION_NAME = "tasks"
export function useTasks() {
const { documents } = usePendingCursorOperation({
collection: COLLECTION_NAME,
})
const { updateByID, upsert, removeByID } = useMutations({
collection: COLLECTION_NAME,
})
// We map over each of the document creating a task object
// that we can use in our UI
const tasks = documents.map((doc) => {
return {
_id: doc.id.value,
body: doc.at("body").register?.value,
isCompleted: doc.at("isCompleted").register?.value,
}
})
function toggle(_id: string) {
updateByID({
_id: _id,
updateClosure(mutableDocument) {
if (mutableDocument) {
const currentValue: boolean =
mutableDocument.at("isCompleted").register?.value || false
mutableDocument.at("isCompleted").set(!currentValue)
}
},
})
}
function updateTaskBody(_id: string, body: string) {
updateByID({
_id: _id,
updateClosure(mutableDocument) {
if (mutableDocument) {
mutableDocument.at("body").set(body)
}
},
})
}
function createTask(body: string) {
upsert({
value: {
body,
isCompleted: false,
},
})
}
function removeTask(_id: string) {
removeByID({
_id: _id,
})
}
return {
tasks,
toggle,
createTask,
removeTask,
updateTaskBody,
}
}
4. Create a new file called `src/NewTaskForm.tsx` and add the following code. This component is responsible for creating a new task.
import React, { useState } from "react"
import { useTasks } from "./DataService"
export function NewTaskForm() {
const [newTaskText, setNewTaskText] = useState<string>("")
const { createTask } = useTasks()
return (
<div className="mt-2 flex rounded-md shadow-sm">
<div className="relative flex flex-grow items-stretch focus-within:z-10">
<input
type="text"
name="newTaskText"
id="newTaskText"
value={newTaskText}
onChange={(e) => {
setNewTaskText(e.target.value)
}}
className="block w-full rounded-none rounded-l-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="New task"
/>
</div>
<button
type="button"
onClick={() => {
createTask(newTaskText)
setNewTaskText("")
}}
className="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Create
</button>
</div>
)
}
5. Create a new file called `src/TaskListItem.tsx` and add the following code. This component is responsible for rendering a single task, and handling the logic for updating, removing, and toggling a task.
import { useEffect, useState } from "react"
import classNames from "classnames"
import { Task } from "./DataService"
interface Props {
task: Task
onRemovePressed: (_id: string) => void
onTogglePressed: (_id: string) => void
onUpdatePressed: (_id: string, body: string) => void
}
export function TaskListItem({
task,
onRemovePressed,
onTogglePressed,
onUpdatePressed,
}: Props) {
const [taskText, setTaskText] = useState<string>("")
useEffect(() => {
setTaskText(task.body)
}, [task.body])
return (
<div className="mt-2 flex rounded-md shadow-sm">
<div className="relative flex flex-grow items-stretch focus-within:z-10">
<input
type="text"
value={taskText}
onChange={(e) => {
setTaskText(e.target.value)
}}
onKeyDown={(e) => {
if (e.code === "Enter") {
onUpdatePressed(task._id, taskText)
e.preventDefault()
}
}}
className={classNames(
"block w-full rounded-none rounded-l-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6",
{
"line-through": task.isCompleted,
}
)}
placeholder="New task"
/>
</div>
<button
type="button"
onClick={() => {
onTogglePressed(task._id)
}}
className="relative -ml-px inline-flex items-center gap-x-1.5 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
{task.isCompleted ? "Mark Incomplete" : "Mark Complete"}
</button>
<button
type="button"
onClick={() => {
onRemovePressed(task._id)
}}
className="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-red-900 ring-1 ring-inset ring-red-300 hover:bg-red-50"
>
Remove
</button>
</div>
)
}
6. Create a new file called `src/TasksPage.tsx` and add the following code. This component is responsible for rendering the list of tasks as wells bootstrapping the form.
import { useTasks } from "./DataService"
import { TaskListItem } from "./TaskListItem"
import { NewTaskForm } from "./NewTaskForm"
export function TasksPage() {
const { removeTask, toggle, tasks, updateTaskBody } = useTasks()
return (
<div className="mt-4 mx-4 max-w-lg">
<h1 className="text-3xl font-bold">Ditto Tasks</h1>
<NewTaskForm />
<hr className="my-5" />
{tasks.map((task) => (
<TaskListItem
key={task._id}
task={task}
onRemovePressed={removeTask}
onTogglePressed={toggle}
onUpdatePressed={updateTaskBody}
/>
))}
</div>
)
}
7. Now edit `src/App.tsx` to render `src/TasksPage.tsx` instead of the default text:
import { Ditto } from "@dittolive/ditto"
import { DittoProvider } from "@dittolive/react-ditto"
import { TasksPage } from "./TasksPage"
console.log(
"[App.tsx]",
`Hello world from Electron ${process.versions.electron}!`
)
const createDittoInstance = () => {
const ditto = new Ditto({
type: "onlinePlayground",
token: "REPLACE_ME",
appID: "REPLACE_ME",
})
ditto.startSync()
return ditto
}
function App() {
return (
<DittoProvider setup={createDittoInstance}>
{({ loading, error }) => {
if (loading) return <span>Loading Ditto...</span>
if (error)
return (
<span>
There was an error loading Ditto. Error: {error.toString()}
</span>
)
return <TasksPage />
}}
</DittoProvider>
)
}
export default App
8. Now `run npm run dev` to start the app. You should see the following:
9. You've probably noticed a `./ditto` directory in the root of your project. This is where the Ditto database is stored. You can delete this directory to reset the app to its initial state. You should add the following to your `.gitignore` file to prevent this directory from being committed to your repository:
/ditto
If you've customized this path in `src/App.tsx`'s `createDittoInstance`, then you should modify the `.gitignore` file to match.
Congratulations! You've just built your first Ditto and Electron app. You can now use Ditto to build apps that sync data across devices the traditional way (through the internet), but you can also build incredible offline-first apps that work even without an internet connection. There are so many ways to customize your electron apps with different frameworks, bundlers, and configuration. We hope this guide has given you a good starting point for building your own Ditto and Electron apps but stay tuned as we will release an `npx` comprehensive starter template shortly!