Skip to content

Commit

Permalink
Add YorkieProvider, DocumentProvider and suspense hooks (#946)
Browse files Browse the repository at this point in the history
YorkieProvider and DocumentProvider are React context providers that enable
easy integration of Yorkie clients and documents into React applications.
They also provide convenient hooks for accessing and updating shared documents
in real-time.

```tsx
<YorkieProvider apiKey={API_KEY}>
  <DocumentProvider
    docKey={DOC_KEY}
    initialRoot={{}}
  >
    <App />
  </DocumentProvider>
</YorkieProvider>

// ...

export default function App() {
  const { root, update, loading, error } = useDocument();

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

// ...
}
```
  • Loading branch information
hackerwins committed Mar 6, 2025
1 parent 1111636 commit 7c98a12
Show file tree
Hide file tree
Showing 14 changed files with 340 additions and 45 deletions.
31 changes: 7 additions & 24 deletions examples/react-todomvc/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,29 @@
import { useDocument, JSONArray, JSONObject } from '@yorkie-js/react';
import 'todomvc-app-css/index.css';
import { JSONArray, JSONObject, useDocument } from '@yorkie-js/react';

import Header from './Header';
import MainSection from './MainSection';
import { Todo } from './model';
import './App.css';

import 'todomvc-app-css/index.css';

/**
* `App` is the root component of the application.
*/
export default function App() {
const { root, update, loading, error } = useDocument<{
todos: JSONArray<Todo>;
}>(
import.meta.env.VITE_YORKIE_API_KEY,
`react-todomvc-${new Date().toISOString().substring(0, 10).replace(/-/g, '')}`,
{
todos: [
{ id: 0, text: 'Yorkie JS SDK', completed: false },
{ id: 1, text: 'Garbage collection', completed: false },
{ id: 2, text: 'RichText datatype', completed: false },
],
},
{
rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,
},
);

if (loading) {
return <div>Loading...</div>;
}
}>();

if (error) {
return <div>Error: {error.message}</div>;
}
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;

const actions = {
addTodo: (text: string) => {
update((root) => {
root.todos.push({
id:
root.todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) +
root.todos.reduce((maxID, todo) => Math.max(todo.id, maxID), -1) +
1,
completed: false,
text,
Expand Down
23 changes: 10 additions & 13 deletions examples/react-todomvc/src/MainSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ interface MainSectionProps {
actions: { [name: string]: Function };
}

export default function MainSection(props: MainSectionProps) {
export default function MainSection({ todos, actions }: MainSectionProps) {
const [filter, setFilter] = useState('SHOW_ALL');
const { todos, actions } = props;
const filteredTodos = todos.filter(TODO_FILTERS[filter]);
const completedCount = todos.reduce((count, todo) => {
return todo.completed ? count + 1 : count;
Expand All @@ -37,17 +36,15 @@ export default function MainSection(props: MainSectionProps) {
onChange={actions.completeAll as ChangeEventHandler}
/>
<ul className="todo-list">
{
filteredTodos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
editTodo={actions.editTodo}
deleteTodo={actions.deleteTodo}
completeTodo={actions.completeTodo}
/>
))
}
{filteredTodos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
editTodo={actions.editTodo}
deleteTodo={actions.deleteTodo}
completeTodo={actions.completeTodo}
/>
))}
</ul>
<Footer
completedCount={completedCount}
Expand Down
19 changes: 17 additions & 2 deletions examples/react-todomvc/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { DocumentProvider, YorkieProvider } from '@yorkie-js/react';

const initalRoot = {
todos: [
{ id: 0, text: 'Yorkie JS SDK', completed: false },
{ id: 1, text: 'Garbage collection', completed: false },
{ id: 2, text: 'RichText datatype', completed: false },
],
};

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<App />,
<YorkieProvider apiKey={import.meta.env.VITE_YORKIE_API_KEY}>
<DocumentProvider
docKey={`react-todomvc-${new Date().toISOString().substring(0, 10).replace(/-/g, '')}`}
initialRoot={initalRoot}
>
<App />
</DocumentProvider>
</YorkieProvider>,
);
19 changes: 19 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Yorkie React SDK

Yorkie React SDK is a library that provides React hooks and components for building collaborative applications with Yorkie.

## How to use React SDK

To get started using Yorkie React SDK, see: https://yorkie.dev/docs/getting-started/with-react

## Contributing

See [CONTRIBUTING](CONTRIBUTING.md) for details on submitting patches and the contribution workflow.

## Contributors ✨

Thanks goes to these incredible people:

<a href="https://github.com/yorkie-team/yorkie-js-sdk/graphs/contributors">
<img src="https://contrib.rocks/image?repo=yorkie-team/yorkie-js-sdk" />
</a>
6 changes: 5 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
{
"name": "@yorkie-js/react",
"version": "0.6.0",
"version": "0.6.1-rc",
"description": "A set of hooks and providers for Yorkie JS SDK",
"main": "./src/index.ts",
"publishConfig": {
"access": "public",
"main": "./dist/yorkie-js-react.js",
"typings": "./dist/yorkie-js-react.d.ts"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
Expand Down
164 changes: 164 additions & 0 deletions packages/react/src/DocumentProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2025 The Yorkie Authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { Document, Presence, Indexable } from '@yorkie-js/sdk';
import { useYorkie } from './YorkieProvider';

type DocumentContextType<R, P extends Indexable = Indexable> = {
root: R;
presences: { clientID: string; presence: P }[];
update: (callback: (root: R) => void) => void;
loading: boolean;
error: Error | undefined;
};

const DocumentContext = createContext<DocumentContextType<any> | null>(null);

/**
* `DocumentProvider` is a component that provides a document to its children.
* This component must be under a `YorkieProvider` component to initialize the
* Yorkie client properly.
*/
export const DocumentProvider = <R, P extends Indexable = Indexable>({
docKey,
initialRoot,
children,
}: {
docKey: string;
initialRoot: R;
children: React.ReactNode;
}) => {
const client = useYorkie();
const [doc, setDoc] = useState<Document<R, P> | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [root, setRoot] = useState(initialRoot);
const [presences, setPresences] = useState<
{ clientID: string; presence: any }[]
>([]);

useEffect(() => {
setLoading(true);
setError(undefined);

if (!client) return;

const newDoc = new Document<R, P>(docKey);
async function attachDocument() {
try {
await client?.attach(newDoc);

newDoc.subscribe(() => {
setRoot({ ...newDoc.getRoot() });
});

newDoc.subscribe('presence', () => {
setPresences(newDoc.getPresences());
});

setDoc(newDoc);
setRoot({ ...newDoc.getRoot() });
} catch (err) {
setError(
err instanceof Error ? err : new Error('Failed to attach document'),
);
} finally {
setLoading(false);
}
}

attachDocument();

return () => {
client.detach(newDoc);
};
}, [client, docKey]);

const update = useCallback(
(callback: (root: R, presence: Presence<P>) => void) => {
if (!doc) {
return;
}

try {
doc.update(callback);
} catch (err) {
setError(
err instanceof Error ? err : new Error('Failed to update document'),
);
}
},
[doc],
);

return (
<DocumentContext.Provider
value={{ root, presences, update, loading, error }}
>
{children}
</DocumentContext.Provider>
);
};

/**
* `useDocument` is a custom hook that returns the root object and update function of the document.
* This hook must be used within a `DocumentProvider`.
*/
export const useDocument = <R, P extends Indexable = Indexable>() => {
const context = useContext(DocumentContext);
if (!context) {
throw new Error('useDocument must be used within a DocumentProvider');
}
return {
root: context.root as R,
presences: context.presences as { clientID: string; presence: P }[],
update: context.update as (
callback: (root: R, presence: P) => void,
) => void,
loading: context.loading,
error: context.error,
};
};

/**
* `useRoot` is a custom hook that returns the root object of the document.
* This hook must be used within a `DocumentProvider`.
*/
export const useRoot = () => {
const context = useContext(DocumentContext);
if (!context) {
throw new Error('useRoot must be used within a DocumentProvider');
}
return { root: context.root };
};

/**
* `usePresences` is a custom hook that returns the presences of the document.
*/
export const usePresences = () => {
const context = useContext(DocumentContext);
if (!context) {
throw new Error('usePresences must be used within a DocumentProvider');
}
return context.presences;
};
72 changes: 72 additions & 0 deletions packages/react/src/YorkieProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2025 The Yorkie Authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState,
} from 'react';
import { Client } from '@yorkie-js/sdk';

const YorkieContext = createContext<{ client: Client | undefined }>({
client: undefined,
});

/**
* `YorkieProviderProps` is a set of properties for `YorkieProvider`.
*/
interface YorkieProviderProps {
apiKey: string;
rpcAddr?: string;
}

/**
* `YorkieProvider` is a component that provides the Yorkie client to its children.
* It initializes the Yorkie client with the given API key and RPC address.
*/
export const YorkieProvider: React.FC<
PropsWithChildren<YorkieProviderProps>
> = ({ apiKey, rpcAddr = 'https://api.yorkie.dev', children }) => {
const [client, setClient] = useState<Client | undefined>(undefined);

useEffect(() => {
async function initClient() {
const newClient = new Client(rpcAddr, {
apiKey,
});
await newClient.activate();
setClient(newClient);
}
initClient();

return () => {
client?.deactivate({ keepalive: true });
};
}, [apiKey, rpcAddr]);

return (
<YorkieContext.Provider value={{ client }}>
{children}
</YorkieContext.Provider>
);
};

export const useYorkie = () => {
const context = useContext(YorkieContext);
return context?.client;
};
Loading

0 comments on commit 7c98a12

Please sign in to comment.