diff --git a/__tests__/mutableAtom.test.tsx b/__tests__/mutableAtom.test.tsx
new file mode 100644
index 0000000..14d4d61
--- /dev/null
+++ b/__tests__/mutableAtom.test.tsx
@@ -0,0 +1,403 @@
+import React from 'react'
+import { act, render, renderHook, waitFor } from '@testing-library/react'
+import { atom, getDefaultStore, useAtom, useAtomValue, useSetAtom } from 'jotai'
+import { mutableAtom } from '../src/mutableAtom'
+import type { ProxyState } from '../src/mutableAtom'
+
+it('should be defined on initial render', async () => {
+ expect.assertions(1)
+ const mutableCountAtom = mutableAtom(0)
+
+ let countProxyIsDefined = false
+ let runCount = 0
+ function Test() {
+ const countProxy = useAtomValue(mutableCountAtom)
+ if (runCount === 0) {
+ countProxyIsDefined = !!countProxy
+ }
+ runCount++
+ return null
+ }
+
+ render()
+ await waitFor(() => assert(runCount > 0))
+ expect(countProxyIsDefined).toBeTruthy()
+})
+
+it('should rerender only when the proxy value changes', async () => {
+ expect.assertions(2)
+ const mutableCountAtom = mutableAtom(0)
+
+ let runCount = 0
+ function useTest() {
+ runCount++
+ const countProxy = useAtomValue(mutableCountAtom)
+ return { countProxy }
+ }
+
+ const { result } = renderHook(useTest)
+
+ // An extra re-render without a commit is an expected behavior
+ expect(runCount).toBe(2)
+
+ await act(async () => result.current.countProxy.value++)
+
+ expect(runCount).toBe(3)
+})
+
+it('should handle updates correctly regardless of mount and unmount events', async () => {
+ expect.assertions(7)
+ const mutableCountAtom = mutableAtom(0)
+ let targetAtom = mutableCountAtom
+ let hasRun = false
+
+ type TestResult = {
+ countProxy: ProxyState
+ }
+ function useTest(): TestResult {
+ hasRun = true
+ const countProxy = useAtomValue(targetAtom)
+ return { countProxy }
+ }
+
+ let result: { current: TestResult }
+ let unmount: () => void
+ function remount() {
+ hasRun = false
+ ;({ result, unmount } = renderHook(useTest))
+ }
+ ;({ result, unmount } = renderHook(useTest))
+ await waitFor(() => assert(hasRun))
+ expect(result.current.countProxy.value).toBe(0)
+
+ await act(async () => result.current.countProxy.value++)
+ expect(result.current.countProxy.value).toBe(1)
+
+ unmount()
+
+ await act(async () => result.current.countProxy.value++)
+ expect(result.current.countProxy.value).toBe(2)
+
+ remount()
+
+ await waitFor(() => assert(hasRun))
+ expect(result.current.countProxy.value).toBe(2)
+
+ await act(async () => result.current.countProxy.value++)
+ expect(result.current.countProxy.value).toBe(3)
+
+ unmount()
+
+ // changing the target atom changes how the atom is mounted
+ targetAtom = atom((get) => get(mutableCountAtom))
+ remount()
+
+ await waitFor(() => assert(hasRun))
+ expect(result.current.countProxy.value).toBe(3)
+
+ await act(async () => result.current.countProxy.value++)
+ expect(result.current.countProxy.value).toBe(4)
+})
+
+it('should cause components to re-render when the proxy value changes', async () => {
+ expect.assertions(6)
+ const mutableCountAtom = mutableAtom(0)
+
+ let runCount = 0
+ function useTest() {
+ runCount++
+ return useAtomValue(mutableCountAtom)
+ }
+
+ const { result, rerender } = renderHook(useTest)
+
+ // the runCount should be 2, an extra re-render without a commit is an expected behavior
+ expect(runCount).toBe(2)
+ expect(result.current.value).toBe(0)
+
+ await act(async () => result.current.value++)
+ expect(runCount).toBe(3)
+ expect(result.current.value).toBe(1)
+
+ rerender()
+ expect(runCount).toBe(4)
+ expect(result.current.value).toBe(1)
+})
+
+it('should proxy nested objects and arrays in the atom state correctly', async () => {
+ expect.assertions(8)
+ const mutableNestedObjectAtom = mutableAtom({
+ array: [1, 2],
+ object: { key: 'value' },
+ })
+
+ let runCount = 0
+ function useTest() {
+ runCount++
+ const nestedObjectProxy = useAtomValue(mutableNestedObjectAtom)
+ return { nestedObjectProxy }
+ }
+
+ const { result } = renderHook(useTest)
+
+ expect(runCount).toBe(2)
+
+ await act(async () => {
+ result.current.nestedObjectProxy.value.array.push(3)
+ expect(result.current.nestedObjectProxy.value.array).toEqual([1, 2, 3])
+ })
+ expect(runCount).toBe(3)
+
+ expect(result.current.nestedObjectProxy.value.array).toEqual([1, 2, 3])
+
+ await act(async () => {
+ result.current.nestedObjectProxy.value.object.key = 'newValue'
+ expect(result.current.nestedObjectProxy.value.object.key).toBe('newValue')
+ })
+ expect(runCount).toBe(4)
+ expect(result.current.nestedObjectProxy.value.array).toEqual([1, 2, 3])
+ expect(result.current.nestedObjectProxy.value.object.key).toBe('newValue')
+})
+
+it('should update all subscribers when the proxy value changes', async () => {
+ expect.assertions(6)
+ const mutableCountAtom = mutableAtom(0)
+
+ let runCount1 = 0
+ let runCount2 = 0
+
+ function useTest1() {
+ runCount1++
+ const countProxy = useAtomValue(mutableCountAtom)
+ return { countProxy }
+ }
+
+ function useTest2() {
+ runCount2++
+ const countProxy = useAtomValue(mutableCountAtom)
+ return { countProxy }
+ }
+
+ const { result: result1 } = renderHook(useTest1)
+ const { result: result2 } = renderHook(useTest2)
+
+ // both components should render for the initial value, an extra re-render without a commit is an expected behavior
+ expect(runCount1).toBe(2)
+ expect(runCount2).toBe(2)
+
+ await act(async () => result1.current.countProxy.value++)
+
+ // both components should rerender when the countProxy value changes
+ expect(runCount1).toBe(3)
+ expect(runCount2).toBe(3)
+
+ await act(async () => result2.current.countProxy.value++)
+
+ // both components should rerender when the countProxy value changes
+ expect(runCount1).toBe(4)
+ expect(runCount2).toBe(4)
+})
+
+it('should correctly handle multiple synchronous updates to the proxy', async () => {
+ expect.assertions(4)
+
+ const mutableCountAtom = mutableAtom(0)
+
+ let runCount = 0
+ function useTest() {
+ runCount++
+ const countProxy = useAtomValue(mutableCountAtom)
+ return { countProxy }
+ }
+
+ // Rendering the hook
+ const { result } = renderHook(useTest)
+
+ // the component renders once initially.
+ expect(runCount).toBe(2)
+ expect(result.current.countProxy.value).toBe(0)
+
+ // Applying multiple synchronous updates to the proxy
+ await act(async () => {
+ result.current.countProxy.value++
+ result.current.countProxy.value++
+ result.current.countProxy.value++
+ })
+
+ // Assertions after updating the proxy
+ expect(result.current.countProxy.value).toBe(3)
+ expect(runCount).toBe(3)
+})
+
+it('should set falsy value', async () => {
+ expect.assertions(1)
+ const mutableCountAtom = mutableAtom(0)
+ function useTest() {
+ const countProxy = useAtomValue(mutableCountAtom)
+ return { countProxy }
+ }
+ const { result } = renderHook(useTest)
+ await act(async () => {
+ result.current.countProxy.value = 0
+ })
+ expect(result.current.countProxy.value).toBe(0)
+})
+
+it('should accept a function as a value', async () => {
+ expect.assertions(4)
+ const mutableFunctionAtom = mutableAtom<() => string>(() => 'foo')
+ let runCount = 0
+ function useTest() {
+ runCount++
+ const functionProxy = useAtomValue(mutableFunctionAtom)
+ return { functionProxy }
+ }
+ const { result } = renderHook(useTest)
+ expect(result.current.functionProxy.value()).toBe('foo')
+ // an extra re-render without a commit is an expected behavior
+ expect(runCount).toBe(2)
+ await act(async () => {
+ result.current.functionProxy.value = () => 'bar'
+ })
+ expect(runCount).toBe(3)
+ expect(result.current.functionProxy.value()).toBe('bar')
+})
+
+it('should reject writing to properties other than `value`', async () => {
+ expect.assertions(2)
+ const mutableCountAtom = mutableAtom(0)
+ function useTest() {
+ const countProxy = useAtomValue(mutableCountAtom)
+ return { countProxy }
+ }
+ const { result } = renderHook(useTest)
+ expect(async () => {
+ await act(async () => {
+ result.current.countProxy.value = 1
+ })
+ }).not.toThrow()
+ expect(() => {
+ // @ts-expect-error attempting to write to a property other than `value`
+ result.current.countProxy.NOT_VALUE = 'TEST'
+ }).toThrow() // 'set' on proxy: trap returned falsish for property 'NOT_VALUE'
+})
+
+it('should allow updating even when component is unmounted', async () => {
+ expect.assertions(2)
+ const store = getDefaultStore()
+ const mutableCountAtom = mutableAtom(0)
+
+ await act(() => store.get(mutableCountAtom).value++)
+
+ function useTest() {
+ return useAtomValue(mutableCountAtom)
+ }
+
+ const { result, unmount } = renderHook(useTest)
+ expect(result.current.value).toBe(1)
+ unmount()
+ await act(() => store.get(mutableCountAtom).value++)
+ expect(store.get(mutableCountAtom).value).toBe(2)
+})
+
+it('should correctly handle updates via writable atom', async () => {
+ expect.assertions(3)
+ const mutableCountAtom = mutableAtom(0)
+ const writableAtom = atom(null, (get, _, value: number) => {
+ const countProxy = get(mutableCountAtom)
+ expect(countProxy.value).toBe(-1)
+
+ countProxy.value = value
+ expect(countProxy.value).toBe(value)
+
+ countProxy.value++
+ expect(countProxy.value).toBe(value + 1)
+ })
+ let isMounted = false
+ writableAtom.onMount = () => {
+ isMounted = true
+ }
+ function useTest() {
+ const countProxy = useAtomValue(mutableCountAtom)
+ const [, setCount] = useAtom(writableAtom)
+ return { countProxy, setCount }
+ }
+ const { result } = renderHook(useTest)
+ await waitFor(() => assert(isMounted))
+ await act(async () => {
+ result.current.countProxy.value--
+ result.current.setCount(1)
+ })
+})
+
+it('should perform synchronous update', async () => {
+ expect.assertions(2)
+ const mutableCountAtom = mutableAtom(0)
+ const countIsNotZeroAtom = atom((get) => get(mutableCountAtom).value > 0)
+ const incrementCountAtom = atom(null, (get) => {
+ const countProxy = get(mutableCountAtom)
+ expect(get(countIsNotZeroAtom)).toBe(false)
+ countProxy.value++
+ expect(get(countIsNotZeroAtom)).toBe(true)
+ })
+ let isMounted = false
+ incrementCountAtom.onMount = () => {
+ isMounted = true
+ }
+ function useTest() {
+ useAtom(countIsNotZeroAtom)
+ const [, incrementCount] = useAtom(incrementCountAtom)
+ return { incrementCount }
+ }
+ const { result } = renderHook(useTest)
+ await waitFor(() => assert(isMounted))
+ await act(async () => {
+ result.current.incrementCount()
+ })
+})
+
+// TODO: fix this infinite loop
+it.skip('should not infinite loop when updating the proxy value in the read function', async () => {
+ expect.assertions(2)
+ const booleanAtom = atom(false)
+ const mutableCountAtom = mutableAtom(0)
+ let runCount = 0
+ let infiniteLoop = false
+ const readUpdateAtom = atom(
+ (get) => {
+ const countProxy = get(mutableCountAtom)
+ if (runCount++ > 10) {
+ infiniteLoop = true
+ return
+ }
+ if (get(booleanAtom)) {
+ countProxy.value++
+ }
+ return countProxy.value
+ },
+ () => {}
+ )
+ let isMounted = false
+ readUpdateAtom.onMount = () => {
+ isMounted = true
+ }
+
+ function useTest() {
+ const count = useAtomValue(readUpdateAtom)
+ const setBoolean = useSetAtom(booleanAtom)
+ return { count, setBoolean }
+ }
+ const { result } = renderHook(useTest)
+ await waitFor(() => assert(isMounted))
+ await act(async () => {
+ result.current.setBoolean(true)
+ })
+ expect(infiniteLoop).toBe(false)
+ expect(result.current.count).toBe(1)
+})
+
+function assert(value: boolean, message?: string): asserts value {
+ if (!value) {
+ throw new Error(message ?? 'assertion failed')
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 3060919..0cba117 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1,3 @@
export { atomWithProxy } from './atomWithProxy'
+
+export { mutableAtom } from './mutableAtom'
diff --git a/src/mutableAtom.ts b/src/mutableAtom.ts
new file mode 100644
index 0000000..f5f4939
--- /dev/null
+++ b/src/mutableAtom.ts
@@ -0,0 +1,121 @@
+import { atom } from 'jotai/vanilla'
+import { proxy, snapshot, subscribe } from 'valtio/vanilla'
+
+type Wrapped = { value: T }
+
+type ProxyFn = (obj: Wrapped) => Wrapped
+
+type Options = {
+ proxyFn?: ProxyFn
+}
+
+export function mutableAtom(
+ initialValue: Value,
+ options: Options = defaultOptions
+) {
+ const valueAtom = atom({ value: initialValue })
+
+ if (process.env.NODE_ENV !== 'production') {
+ valueAtom.debugPrivate = true
+ }
+
+ const { proxyFn } = { ...defaultOptions, ...options }
+
+ const storeAtom = atom<
+ Store,
+ [ActionWithPayload<'setValue', Value> | Action<'getValue'>],
+ void | Value
+ >(
+ (_get, { setSelf }) => {
+ const store: Store = {
+ proxyState: createProxyState(() => store),
+ getValue: () => setSelf({ type: 'getValue' }) as Value,
+ setValue: (value: Value) =>
+ setSelf({ type: 'setValue', payload: value }) as void,
+ }
+ return store
+ },
+ (get, set, action) => {
+ if (action.type === 'setValue') {
+ set(valueAtom, { value: action.payload })
+ } else if (action.type === 'getValue') {
+ return get(valueAtom).value
+ }
+ }
+ )
+
+ if (process.env.NODE_ENV !== 'production') {
+ storeAtom.debugPrivate = true
+ }
+
+ /**
+ * sync the proxy state with the atom
+ */
+ function onChange(getStore: () => Store) {
+ return () => {
+ const { proxyState, getValue, setValue } = getStore()
+ const { value } = snapshot(proxyState)
+ if (!Object.is(value, getValue())) {
+ setValue(value as Awaited)
+ }
+ }
+ }
+
+ /**
+ * create the proxy state and subscribe to it
+ */
+ function createProxyState(getStore: () => Store) {
+ const proxyState = proxyFn({ value: initialValue })
+ // We never unsubscribe, but it's garbage collectable.
+ subscribe(proxyState, onChange(getStore), true)
+ return proxyState
+ }
+
+ /**
+ * wrap the proxy state in a proxy to ensure rerender on value change
+ */
+ function wrapProxyState(proxyState: ProxyState) {
+ return new Proxy(proxyState, {
+ get(target, property) {
+ return target[property as keyof ProxyState]
+ },
+ set(target, property, value) {
+ if (property === 'value') {
+ target[property] = value
+ return true
+ }
+ return false
+ },
+ })
+ }
+
+ /**
+ * create an atom that returns the proxy state
+ */
+ const proxyEffectAtom = atom>((get) => {
+ get(valueAtom) // subscribe to value updates
+ const store = get(storeAtom)
+ return wrapProxyState(store.proxyState)
+ })
+ return proxyEffectAtom
+}
+
+const defaultOptions = {
+ proxyFn: proxy,
+}
+
+type Store = {
+ proxyState: ProxyState
+ getValue: () => Value
+ setValue: (value: Value) => void
+}
+
+export type ProxyState = { value: Value }
+
+type Action = {
+ type: Type
+}
+
+type ActionWithPayload = Action & {
+ payload: Payload
+}