Getting to re-know an old friend đź«‚
A while ago, I started developing with React (again). Coming from Vue, it was quite a shift in terms of building frontend UIs.
Parent-to-child data flow, component rendering intricacies, lifecycle hooks, and context for state management to avoid prop drilling—it was a lot to grasp just from reading documentation.
So, I decided that my next library—whenever that happened—would be built to support both Vue and React. It should give me a much better understanding on direct differences between the two.
The Idea: A Panes library 🪟
I really like Linear’s user experience—it's intuitive yet unobtrusive. You might never think to drag the panes, but if you try, it just works. Resize the window? The right pane collapses automatically. "It just works."
So I decided that's what I'm going to build, a panes library that gives you the tools to build such a dashboard. Which is now TurtlePanes.
Initial Exploration đź‘€
I liked React’s Context API—it prevents prop drilling and serves as a shared state. Perfect.
So the blueprint was simple: composable components that relied on a shared context. In theory, a great idea.
I started with Vue, and the initial implementation was quick, but I was writing too much business logic inside Vue components. Eventually, I moved most logic into the context object, keeping the components clean.
The Vue version was working great, but then I hit a major roadblock while porting it to React.
Vue vs. React Reactivity Differences ⚖️
Vue has a reactive
function that transforms an object into a reactive one, allowing the framework to track changes and trigger re-renders. React, however, handles reactivity differently. A state variable must be updated using its corresponding setter function—otherwise, no re-render occurs.
The JavaScript object itself updates, but React won’t reflect the updates.
const [sharedState, setSharedState] = useState({ count: 1 });
sharedState.count++; // 🙅🏻‍♂️ Updates the object, but won't trigger a re-render
setSharedState((prev) => ({ ...prev, count: prev.count + 1 })); // âś… Correct approach
I explored libraries like Valtio and Mitosis, but neither fit perfectly. I didn’t want to add a peer dependency for Valtio, and Mitosis didn’t work well with a shared context.
Instead of maintaining separate contexts for Vue and React, I decided to use a single core state and “hack” React’s update mechanism by listening for changes on a JavaScript object.
The Solution: A Shared Proxy-Based State đź’ˇ
The core state provides methods to create a state object and a proxy wrapper that listens for changes, triggering updates as needed. (As a bonus, Vue’s reactivity mechanism actually works in a similar way, using Proxies to track changes and update the UI automatically.)
// context.ts
export const createState = () => ({ count: 0 });
export const createActions = (state) => ({
updateCount: () => state.count++
});
export const createProxyState = (state, onUpdateCallback) => {
return new Proxy(state, {
set(target, prop, value) {
target[prop] = value;
onUpdateCallback();
}
});
};
With these three functions, we should now have everything to share the state between the two.
Vue Adapter
Now we need to create a Vue adapter, which uses 'reactive' hook to turn it into a digestable object for Vue components.
// vue-state-adapter.ts
import { createState, createActions } from './context.ts';
import { reactive, provide, inject } from 'vue';
export const createContext = () => {
const state = reactive(createState());
const actions = createActions(state);
return { state, ...actions };
};
<script>
// Usage in a Vue component
import { createContext } from "./vue-state-adapter";
const context = createContext();
provide("context", context);
</script>
<template>
<div>
<p>Count: {{ context.state.count }}</p>
<button @click="context.updateCount">Increment</button>
</div>
</template>
React Adapter
And likewise for React, we need to make sure the UI updates everytime something changes in the proxied state.
// react-state-adapter.tsx
import { createContext, useRef, useState, useMemo, useContext } from "react";
import { createState, createProxyState, createActions } from "@turtle-panes/core";
const StateContext = createContext(undefined);
export const StateProvider = ({ children }) => {
const [triggerRerender, setTriggerRerender] = useState(0);
const state = useRef(null);
if (!state.current) {
const initialState = createState();
state.current = createProxyState(initialState, () => setTriggerRerender((prev) => prev + 1));
}
const actions = useRef(null);
if (!actions.current) {
actions.current = createActions(state.current);
}
const contextValue = useMemo(() => {
return { state: state.current, ...actions.current };
}, [triggerRerender]);
return <StateContext.Provider value={contextValue}>{children}</StateContext.Provider>;
};
export const useStateContext = () => {
const context = useContext(StateContext);
if (context === undefined) {
throw new Error("useStateContext must be used within a StateProvider");
}
return context;
};
Using It in a React Component
import { StateProvider, useStateContext } from "./react-state-adapter";
const Counter = () => {
const { state, updateCount } = useStateContext();
return (
<div>
<p>Count: {state.count}</p>
<button onClick={updateCount}>Increment</button>
</div>
);
};
const App = () => (
<StateProvider>
<Counter />
</StateProvider>
);
export default App;
Conclusion 🙆🏻‍♂️
This approach provides a framework-agnostic way to manage state across React and Vue while keeping a unified core. The proxy trick ensures React detects state changes without requiring additional dependencies. The result? A lightweight, scalable solution that “just works.”
I look forward to building more libraries like this, and maybe even start with a core library that uses vanilla JS, and then have framework adapters built around that instead.
Let me know if there's something I missed, or some feedback you might have, whatever it may be.
You can find the library here: http://github.com/altinselimi/turtle-panes
Top comments (0)