├── .github └── workflows │ └── npmpublish.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── __tests__ └── Refractive_test.re ├── bin └── gen-selectors.js ├── bsconfig.json ├── examples ├── counter │ ├── CounterApp.re │ ├── CounterStore.re │ └── index.html ├── grid-concurrent │ ├── GridConcurrentApp.re │ ├── ReactConcurrentMode.re │ └── index.html ├── grid-reductive │ ├── GridReductiveApp.re │ ├── GridReductiveStore.re │ └── index.html └── grid │ ├── GridApp.re │ ├── GridStore.re │ └── index.html ├── package-lock.json ├── package.json ├── src ├── Refractive.re ├── Refractive.rei ├── Refractive__Context.re ├── Refractive__Lens.re ├── Refractive__Selector.re ├── Refractive__Subscription.re └── Refractive__TrackedSelector.re └── webpack.config.js /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: 12 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 34 | 35 | publish-gpr: 36 | needs: build 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions/setup-node@v1 41 | with: 42 | node-version: 12 43 | registry-url: https://npm.pkg.github.com/ 44 | scope: '@tizoc' 45 | - run: npm ci 46 | - run: npm publish 47 | env: 48 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .merlin 3 | .vscode 4 | /node_modules/ 5 | /lib/ 6 | /bundledOutputs/ 7 | npm-debug.log 8 | /_build/ 9 | *.bs.js 10 | .bsb.lock 11 | /tmp 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .merlin 2 | .vscode 3 | /lib 4 | /bundledOutputs 5 | npm-debug.log 6 | package-lock.json 7 | *.bs.js 8 | .bsb.lock 9 | /tmp 10 | /.github 11 | /_build 12 | /_esy 13 | /.vscode 14 | /examples 15 | /__tests__ 16 | webpack.config.js 17 | esy.json 18 | dune-project -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Bruno Deferrari 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 3. The name of the author may not be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 17 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 18 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 19 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 20 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 21 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 22 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 24 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Refractive 2 | 3 | [![npm version](https://badge.fury.io/js/%40tizoc%2Frefractive.svg)](https://badge.fury.io/js/%40tizoc%2Frefractive) 4 | 5 | Lenses and tracked selectors hooks for [reductive](https://github.com/reasonml-community/reductive). 6 | 7 | A PPX rewriter that generates lenses and selectors for record types can be found [here](https://github.com/tizoc/refractive-ppx). 8 | 9 | ## NOTE 10 | 11 | **This is proof of concept at the moment, and while it works, the API is not stable.** 12 | 13 | ## Preview 14 | 15 | ```reason 16 | module Store = { 17 | // Optional: with the [@refractive.derive] annotation the `Lenses` and `Selectors` 18 | // modules will be autogenerated. Not used in this example. 19 | // [@refractive.derive] 20 | type state = {counter: int}; 21 | 22 | type action = 23 | | Increment 24 | | Decrement; 25 | 26 | // Lenses specify how to read and update part 27 | // of the state in a functional way. 28 | module Lenses = { 29 | let counter = 30 | Refractive.Lens.make( 31 | ~get=s => s.counter, 32 | ~set=(newVal, s) => {...s, counter: newVal}, 33 | ); 34 | }; 35 | 36 | // Selectors are the combination of a Lense and 37 | // a path to the value it affects. 38 | module Selectors = { 39 | let counter = 40 | Refractive.Selector.make(~lens=Lenses.counter, ~path=[|"counter"|]); 41 | }; 42 | 43 | // Module for tracked selectors and updates 44 | // This module's `modify` and `set` functions must be used to update the state. 45 | include Refractive.TrackedSelector.Make({}); 46 | 47 | // Reducer function, updates must be made using the `modify` and `set` functinos 48 | // from the instantiated tracked selectors module. 49 | let reducer = (state, action) => { 50 | Selectors.( 51 | switch (action) { 52 | | Increment => modify(counter, n => n + 1, state) 53 | | Decrement => modify(counter, n => n - 1, state) 54 | } 55 | ); 56 | }; 57 | 58 | let store = 59 | Reductive.Store.create(~reducer, ~preloadedState={counter: 0}, ()); 60 | }; 61 | 62 | // This module contains the `useDispatch` and `useSelector` hooks 63 | // and a Provider component that provides the necessary context 64 | // for those hooks. 65 | // Instantiating this module also creates a new subscription 66 | // for the selectors. 67 | module StoreContext = Refractive.Context.Make(Store); 68 | 69 | module AppComponent = { 70 | open StoreContext.Hooks; // useDispatch and useSelector 71 | 72 | [@react.component] 73 | let make = () => { 74 | let dispatch = useDispatch(); 75 | let handleIncr = React.useCallback0(_ => dispatch(Store.Increment)); 76 | let handleDecr = React.useCallback0(_ => dispatch(Store.Decrement)); 77 | let value = useSelector(Store.Selectors.counter); 78 | 79 | 80 | {React.string(string_of_int(value))} 81 | 82 | 83 | ; 84 | }; 85 | }; 86 | 87 | ReactDOMRe.renderToElementWithId(, "app-root"); 88 | ``` 89 | 90 | See `examples/` directory for more examples. 91 | 92 | ## The problem 93 | 94 | The way Redux and Reductive work, every component that is interested in part of the store's state value subscribes to changes to the store, and receives a notification after an action has been dispatched. But most components care only about a small part of the store state, and most of the time it doesn't change often. This means that a lot of unnecessary notifications are executed. When there aren't too many components subscribed to the store, this is not an issue, but as more components get subscribed to the store, more overhead accumulates. 95 | 96 | ## Refractive's solution 97 | 98 | Refractive avoids this problem by subscribing to changes to the stoure through **selectors** that encode a path to the part of the store's state that the component is interested in. Reducer functions update the store using **selectors** and the special `set` and `modify` functions. Modifications made using these functions are tracked, and only the components that are subscribed to values affected during the reducing function execution are notified of the change. 99 | 100 | ## Examples 101 | 102 | To run the example: 103 | 104 | npm install 105 | npm run build 106 | npx webpack 107 | 108 | and then open `index.html` of any of the examples under the `examples/` directory in your browser. 109 | -------------------------------------------------------------------------------- /__tests__/Refractive_test.re: -------------------------------------------------------------------------------- 1 | open Jest; 2 | open Expect; 3 | 4 | describe("Unfold path", () => { 5 | open Refractive__Selector; 6 | 7 | test("Unfolds straight path correctly", () => { 8 | expect(Array.of_list @@ unfoldPath(Straight([|"c", "b", "a"|]))) 9 | |> toEqual([|"a.b.c", "a.b", "a"|]) 10 | }); 11 | 12 | test("Unfolds forked path with empty prefix correctly", () => { 13 | expect( 14 | Array.of_list @@ 15 | unfoldPath( 16 | Forked( 17 | [||], 18 | [Straight([|"c", "b", "a"|]), Straight([|"b"|])], 19 | "join", 20 | None, 21 | ), 22 | ), 23 | ) 24 | |> toEqual([|"a.b.c", "a.b", "a", "b"|]) 25 | }); 26 | 27 | test("Unfolds forked path with prefix correctly", () => { 28 | expect( 29 | Array.of_list @@ 30 | unfoldPath( 31 | Forked( 32 | [|"p1", "p0"|], 33 | [Straight([|"c", "b", "a"|]), Straight([|"f", "e"|])], 34 | "join", 35 | None, 36 | ), 37 | ), 38 | ) 39 | |> toEqual([| 40 | "p0.p1.a.b.c", 41 | "p0.p1.a.b", 42 | "p0.p1.e.f", 43 | "p0.p1.a", 44 | "p0.p1.e", 45 | "p0.p1", 46 | "p0", 47 | |]) 48 | }); 49 | 50 | test("Unfolds more complicated forked path with prefix correctly", () => { 51 | expect( 52 | Array.of_list @@ 53 | unfoldPath( 54 | Forked( 55 | [|"p1", "p0"|], 56 | [ 57 | Straight([|"c", "b", "a"|]), 58 | Forked( 59 | [||], 60 | [Straight([|"h", "g"|]), Straight([|"f", "e"|])], 61 | "join", 62 | None, 63 | ), 64 | ], 65 | "join", 66 | None, 67 | ), 68 | ), 69 | ) 70 | |> toEqual([| 71 | "p0.p1.a.b.c", 72 | "p0.p1.a.b", 73 | "p0.p1.e.f", 74 | "p0.p1.g.h", 75 | "p0.p1.a", 76 | "p0.p1.e", 77 | "p0.p1.g", 78 | "p0.p1", 79 | "p0", 80 | |]) 81 | }); 82 | }); 83 | 84 | describe("Selector paths", () => { 85 | open Refractive__Selector; 86 | 87 | test("String rendering of straight paths", () => { 88 | expect(stringOfPath(Straight([|"b"|]))) |> toEqual("b") 89 | }); 90 | 91 | test("String rendering of another straight path", () => { 92 | expect(stringOfPath(Straight([|"a", "b", "c"|]))) |> toEqual("c.b.a") 93 | }); 94 | 95 | test("String rendering of forked path without prefix", () => { 96 | expect( 97 | stringOfPath( 98 | Forked( 99 | [||], 100 | [Straight([|"c", "b", "a"|]), Straight([|"b"|])], 101 | "join", 102 | None, 103 | ), 104 | ), 105 | ) 106 | |> toEqual("join(a.b.c, b)") 107 | }); 108 | 109 | test("String rendering of forked path with prefix", () => { 110 | expect( 111 | stringOfPath( 112 | Forked( 113 | [|"p1", "p0"|], 114 | [Straight([|"c", "b", "a"|]), Straight([|"b"|])], 115 | "join", 116 | None, 117 | ), 118 | ), 119 | ) 120 | |> toEqual("p0.p1.join(a.b.c, b)") 121 | }); 122 | 123 | test("Composing two straight paths", () => { 124 | expect( 125 | stringOfPath( 126 | composePath(Straight([|"p1", "p0"|]), Straight([|"c", "b", "a"|])), 127 | ), 128 | ) 129 | |> toEqual("p0.p1.a.b.c") 130 | }); 131 | 132 | test("Composing a straight path with a fork", () => { 133 | expect( 134 | stringOfPath( 135 | composePath( 136 | Straight([|"p1", "p0"|]), 137 | Forked( 138 | [||], 139 | [ 140 | Straight([|"c", "b", "a"|]), 141 | Straight([|"b", "a"|]), 142 | Straight([|"b"|]), 143 | ], 144 | "join", 145 | None, 146 | ), 147 | ), 148 | ), 149 | ) 150 | |> toEqual("p0.p1.join(a.b.c, a.b, b)") 151 | }); 152 | 153 | test("Composing a forked path with a straight path", () => { 154 | expect( 155 | stringOfPath( 156 | composePath( 157 | Forked( 158 | [||], 159 | [ 160 | Straight([|"c", "b", "a"|]), 161 | Straight([|"b", "a"|]), 162 | Straight([|"b"|]), 163 | ], 164 | "join", 165 | None, 166 | ), 167 | Straight([|"p1", "p0"|]), 168 | ), 169 | ), 170 | ) 171 | |> toEqual("join(a.b.c, a.b, b).p0.p1") 172 | }); 173 | 174 | test("Composing a forked path with a forked path", () => { 175 | expect( 176 | stringOfPath( 177 | composePath( 178 | Forked( 179 | [|"d"|], 180 | [ 181 | Straight([|"c", "b", "a"|]), 182 | Straight([|"b", "a"|]), 183 | Straight([|"b"|]), 184 | ], 185 | "join", 186 | None, 187 | ), 188 | Forked( 189 | [|"z"|], 190 | [ 191 | Straight([|"x"|]), 192 | Straight([|"y"|]), 193 | ], 194 | "join", 195 | None, 196 | ), 197 | ), 198 | ), 199 | ) 200 | |> toEqual("d.join(a.b.c, a.b, b).z.join(x, y)") 201 | }); 202 | 203 | test("Joined paths are constructed correctly", () => { 204 | let s1 = make(~lens=Lens.const(1), ~path=[|"p1", "p0"|]); 205 | let s2 = make(~lens=Lens.const(1), ~path=[|"p3", "p2"|]); 206 | let mapped = map2(~name="map2", (a, b) => a + b, s1, s2); 207 | expect(mapped.path) 208 | |> toEqual( 209 | Forked( 210 | [||], 211 | [Straight([|"p1", "p0"|]), Straight([|"p3", "p2"|])], 212 | "map2", 213 | None, 214 | ), 215 | ); 216 | }); 217 | 218 | test("Joined paths are printed correctly", () => { 219 | let s1 = make(~lens=Lens.const(1), ~path=[|"p1", "p0"|]); 220 | let s2 = make(~lens=Lens.const(1), ~path=[|"p3", "p2"|]); 221 | let mapped = map2(~name="map2", (a, b) => a + b, s1, s2); 222 | expect(stringOfPath(mapped.path)) |> toEqual("map2(p0.p1, p2.p3)"); 223 | }); 224 | 225 | test("Joined paths are joined correctly", () => { 226 | let s1 = make(~lens=Lens.const(1), ~path=[|"p1", "p0"|]); 227 | let s2 = make(~lens=Lens.const(1), ~path=[|"p3", "p2"|]); 228 | let mapped1 = map2(~name="map2", (a, b) => a + b, s1, s2); 229 | let mapped2 = map2(~name="map2", (a, b) => a - b, s1, s2); 230 | let mapped = map2(~name="join", (a, b) => a * b, mapped1, mapped2); 231 | expect(stringOfPath(mapped.path)) 232 | |> toEqual("join(map2(p0.p1, p2.p3), map2(p0.p1, p2.p3))"); 233 | }); 234 | }); 235 | 236 | module TestStore = { 237 | type state = { 238 | counters: array(int), 239 | textA: string, 240 | textB: string, 241 | }; 242 | let initialValue = {counters: [|0, 0, 0, 0, 0|], textA: "A", textB: "B"}; 243 | 244 | type action = 245 | | Reset 246 | | SetTextA(string) 247 | | SetTextB(string) 248 | | IncrementAll 249 | | IncrementCounter(int); 250 | 251 | module Lenses = { 252 | open Refractive.Lens; 253 | let counters = 254 | make(~get=x => x.counters, ~set=(counters, x) => {...x, counters}); 255 | let textA = 256 | make(~get=x => x.textA, ~set=(newVal, x) => {...x, textA: newVal}); 257 | let textB = 258 | make(~get=x => x.textB, ~set=(newVal, x) => {...x, textB: newVal}); 259 | }; 260 | 261 | module Selectors = { 262 | open Refractive.Selector; 263 | let (|-) = compose; 264 | let counters = make(~lens=Lenses.counters, ~path=[|"counter"|]); 265 | let countersCount = counters |- arrayLength(0); 266 | let counterValue = i => counters |- arrayIndex(i); 267 | let textA = make(~lens=Lenses.textA, ~path=[|"textA"|]); 268 | let textB = make(~lens=Lenses.textB, ~path=[|"textB"|]); 269 | }; 270 | 271 | include Refractive.TrackedSelector.Make({}); 272 | 273 | let reducer = (state, action) => { 274 | Selectors.( 275 | switch (action) { 276 | | Reset => 277 | state 278 | |> set(counters, initialValue.counters) 279 | |> set(textA, initialValue.textA) 280 | |> set(textB, initialValue.textB) 281 | | SetTextA(s) => set(textA, s, state) 282 | | SetTextB(s) => set(textB, s, state) 283 | | IncrementCounter(index) => 284 | modify(counterValue(index), n => n + 1, state) 285 | | IncrementAll => 286 | modify(counters, c => Array.map(n => n + 1, c), state) 287 | } 288 | ); 289 | }; 290 | 291 | let store = 292 | Reductive.Store.create(~reducer, ~preloadedState=initialValue, ()); 293 | }; 294 | 295 | module TestStoreContext = Refractive.Context.Make(TestStore); 296 | 297 | let revArray = list => List.rev(list) |> Array.of_list; 298 | 299 | describe("useSelector", () => { 300 | open ReasonHooksTestingLibrary.Testing; 301 | open ReactTestingLibrary; 302 | 303 | module Wrapper = { 304 | [@react.component] 305 | let make = (~children) => { 306 | children ; 307 | }; 308 | }; 309 | 310 | module Selectors = TestStore.Selectors; 311 | 312 | let useSelector = TestStoreContext.Hooks.useSelector; 313 | 314 | let renderHookWrapped = render => 315 | renderHook(render, ~options=Options.t(~wrapper=Wrapper.make, ()), ()); 316 | 317 | beforeEach(() => 318 | Refractive.Store.dispatch(TestStore.store, TestStore.Reset) 319 | ); 320 | 321 | test("returns the correct selector value", () => { 322 | let container = renderHookWrapped(() => useSelector(Selectors.textA)); 323 | 324 | expect(Result.(container->result->current)) |> toEqual("A"); 325 | }); 326 | 327 | test("returns the correct selector value after a modification", () => { 328 | let container = renderHookWrapped(() => useSelector(Selectors.textA)); 329 | 330 | act(() => 331 | Reductive.Store.dispatch(TestStore.store, TestStore.SetTextA("Z")) 332 | ); 333 | 334 | expect(Result.(container->result->current)) |> toEqual("Z"); 335 | }); 336 | 337 | test( 338 | "replacing parent value runs notifications for subscriptions on children", 339 | () => { 340 | let renderedStates = ref([]); 341 | 342 | module Component = { 343 | [@react.component] 344 | let make = () => { 345 | let value = useSelector(Selectors.counterValue(0)); 346 | renderedStates := [value, ...renderedStates^]; 347 | 348 |
{React.string(string_of_int(value))}
; 349 | }; 350 | }; 351 | 352 | ignore @@ render( ); 353 | 354 | act(() => { 355 | Reductive.Store.dispatch(TestStore.store, TestStore.IncrementAll); 356 | Reductive.Store.dispatch(TestStore.store, TestStore.IncrementAll); 357 | }); 358 | 359 | expect(revArray(renderedStates^)) |> toEqual([|0, 1, 2|]); 360 | }); 361 | 362 | // NOTE: we count calls to the component render functions 363 | // and equate that to the amount of times the notification 364 | // callback for that component was called. This works 365 | // because (for now at least) setState (which is always invoked by 366 | // the callback) will trigger a call to the render function, 367 | // even when the value returned by the update function is 368 | // the same of the original[1]. Because of this, we can assume 369 | // that if render was not called, then the notification 370 | // callback function wasn't called either. 371 | // 372 | // [1]: https://github.com/facebook/react/issues/14994 373 | 374 | test("only components subscribed to path get notified", () => { 375 | let renderCountA = ref(0); 376 | let renderCountB = ref(0); 377 | 378 | module ComponentA = { 379 | [@react.component] 380 | let make = () => { 381 | let value = useSelector(Selectors.textA); 382 | incr(renderCountA); 383 | 384 |
{React.string(value)}
; 385 | }; 386 | }; 387 | 388 | module ComponentB = { 389 | [@react.component] 390 | let make = () => { 391 | let value = useSelector(Selectors.textB); 392 | incr(renderCountB); 393 | 394 |
{React.string(value)}
; 395 | }; 396 | }; 397 | 398 | ignore @@ render( ); 399 | 400 | act(() => { 401 | Reductive.Store.dispatch(TestStore.store, TestStore.SetTextB("C")); 402 | Reductive.Store.dispatch(TestStore.store, TestStore.SetTextB("D")); 403 | }); 404 | 405 | expect((renderCountA^, renderCountB^)) |> toEqual((1, 3)); 406 | }); 407 | 408 | test("selector that uses props on instantiation works as expected", () => { 409 | let renderedStates = ref([]); 410 | let incrIndex = ref(() => ()); 411 | 412 | module Component = { 413 | [@react.component] 414 | let make = (~index) => { 415 | let value = useSelector(Selectors.counterValue(index)); 416 | renderedStates := [value, ...renderedStates^]; 417 | 418 |
{React.string(string_of_int(value))}
; 419 | }; 420 | }; 421 | 422 | module Parent = { 423 | [@react.component] 424 | let make = () => { 425 | let (index, dispatch) = React.useReducer((s, _) => s + 1, 0); 426 | incrIndex := dispatch; 427 | ; 428 | }; 429 | }; 430 | 431 | ignore @@ render( ); 432 | 433 | act(() => { 434 | Reductive.Store.dispatch( 435 | TestStore.store, 436 | TestStore.IncrementCounter(1), 437 | ); 438 | Reductive.Store.dispatch( 439 | TestStore.store, 440 | TestStore.IncrementCounter(2), 441 | ); 442 | Reductive.Store.dispatch( 443 | TestStore.store, 444 | TestStore.IncrementCounter(2), 445 | ); 446 | incrIndex^(); 447 | incrIndex^(); 448 | }); 449 | 450 | // States are duplicated because every time 451 | // the prop changes the selector changes too, which results 452 | // in one call to render because of the prop change and 453 | // another call because of the selector change 454 | expect(revArray(renderedStates^)) |> toEqual([|0, 1, 1, 2, 2|]); 455 | }); 456 | 457 | test( 458 | "changing child runs notifications for subscriptions on parent paths", () => { 459 | let renderedStates = ref([]); 460 | 461 | module Component = { 462 | [@react.component] 463 | let make = () => { 464 | let counters = useSelector(Selectors.counters); 465 | let sum = Belt.Array.reduce(counters, 0, (+)); 466 | 467 | renderedStates := [sum, ...renderedStates^]; 468 | 469 |
{React.string(string_of_int(sum))}
; 470 | }; 471 | }; 472 | 473 | ignore @@ render( ); 474 | 475 | act(() => { 476 | Reductive.Store.dispatch( 477 | TestStore.store, 478 | TestStore.IncrementCounter(1), 479 | ); 480 | Reductive.Store.dispatch( 481 | TestStore.store, 482 | TestStore.IncrementCounter(2), 483 | ); 484 | }); 485 | 486 | expect(revArray(renderedStates^)) |> toEqual([|0, 1, 2|]); 487 | }); 488 | }); -------------------------------------------------------------------------------- /bin/gen-selectors.js: -------------------------------------------------------------------------------- 1 | // Temporary solution to automate the generation of lenses and selectors 2 | // for the fields of a record definition. 3 | 4 | // The PPX rewriters story in bucklescript seems a bit complicated at the moment. 5 | // This script is a poor man's substitute for a rewriter that derives 6 | // lenses and selectors from a type definition. 7 | // To use pass a list of field names to stdin, one per line. 8 | // This will output implementations for Lenses and Selectors modules. 9 | // To generate interface files for such modules, the method described here 10 | // can be used: 11 | // https://bucklescript.github.io/docs/en/automatic-interface-generation 12 | const fs = require('fs'); 13 | 14 | const emitLens = name => { 15 | console.log(` let ${name} = Refractive.Lens.make(~get=x => x.${name}, ~set=(newVal, x) => { ...x, ${name}: newVal });`); 16 | } 17 | 18 | const emitSelector = name => { 19 | console.log(` let ${name} = Refractive.Selector.make(~lens=Lenses.${name}, ~path=[|\"${name}\"|]);`); 20 | } 21 | 22 | const emitLensesModule = names => { 23 | console.log("module Lenses = {"); 24 | names.forEach(emitLens); 25 | console.log("};"); 26 | } 27 | 28 | const emitSelectorsModule = names => { 29 | console.log("module Selectors = {"); 30 | names.forEach(emitSelector, names); 31 | console.log("};"); 32 | } 33 | 34 | const stdinBuffer = fs.readFileSync(0); 35 | const names = stdinBuffer.toString().split("\n").filter(s => s !== ''); 36 | 37 | emitLensesModule(names); 38 | console.log(); 39 | emitSelectorsModule(names); -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tizoc/refractive", 3 | "reason": { 4 | "react-jsx": 3 5 | }, 6 | "sources": [ 7 | "src", 8 | { 9 | "dir": "examples", 10 | "subdirs": [ 11 | "counter", 12 | "grid", 13 | "grid-concurrent", 14 | "grid-reductive" 15 | ], 16 | "type": "dev" 17 | }, 18 | { 19 | "dir": "__tests__", 20 | "type": "dev" 21 | } 22 | ], 23 | "suffix": ".bs.js", 24 | "namespace": false, 25 | "bs-dependencies": [ 26 | "reason-react", 27 | "reductive" 28 | ], 29 | "bs-dev-dependencies": [ 30 | "@glennsl/bs-jest", 31 | "bs-react-testing-library", 32 | "reason-hooks-testing-library" 33 | ], 34 | "refmt": 3 35 | } -------------------------------------------------------------------------------- /examples/counter/CounterApp.re: -------------------------------------------------------------------------------- 1 | module Selectors = CounterStore.Selectors; 2 | module StoreContext = Refractive.Context.Make(CounterStore); 3 | open StoreContext.Hooks; 4 | 5 | module CounterDisplay = { 6 | let style = ReactDOMRe.Style.make(~margin="1em", ()); 7 | 8 | [@react.component] 9 | let make = (~index) => { 10 | let selector = 11 | React.useMemo1(() => Selectors.counterValue(index), [|index|]); 12 | let count = useSelector(selector); 13 | 14 | {React.string(string_of_int(count))} ; 15 | }; 16 | 17 | let make = React.memo(make); 18 | }; 19 | 20 | module CounterEditor = { 21 | [@react.component] 22 | let make = (~index) => { 23 | let dispatch = useDispatch(); 24 | let increment = 25 | React.useCallback1( 26 | _ => dispatch(CounterStore.IncrementCounter(index)), 27 | [|index|], 28 | ); 29 | let decrement = 30 | React.useCallback1( 31 | _ => dispatch(CounterStore.DecrementCounter(index)), 32 | [|index|], 33 | ); 34 | 35 |
36 | 37 | 38 | 39 |
; 40 | }; 41 | 42 | let make = React.memo(make); 43 | }; 44 | 45 | module CountersControls = { 46 | [@react.component] 47 | let make = () => { 48 | let dispatch = useDispatch(); 49 | let count = useSelector(Selectors.countersCount); 50 | let append = React.useCallback(_ => dispatch(CounterStore.AppendCounter)); 51 | let removeLast = 52 | React.useCallback(_ => dispatch(CounterStore.RemoveLastCounter)); 53 | 54 |
55 | 56 | 57 |
    58 | {React.array( 59 | Belt.Array.makeBy(count, index => 60 | 61 | ), 62 | )} 63 |
64 |
; 65 | }; 66 | 67 | let make = React.memo(make); 68 | }; 69 | 70 | module CountersSequence = { 71 | [@react.component] 72 | let make = () => { 73 | let count = useSelector(Selectors.countersCount); 74 | 75 |
76 | {React.array( 77 | Belt.Array.makeBy(count, index => 78 | 79 | ), 80 | )} 81 |
; 82 | }; 83 | 84 | let make = React.memo(make); 85 | }; 86 | 87 | module CountersSum = { 88 | [@react.component] 89 | let make = () => { 90 | let counters = useSelector(Selectors.counters); 91 | let sum = Belt.Array.reduce(counters, 0, (+)); 92 | 93 |
{React.string(string_of_int(sum))}
; 94 | }; 95 | 96 | let make = React.memo(make); 97 | }; 98 | 99 | module App = { 100 | [@react.component] 101 | let make = () => { 102 | 103 |

{React.string("Counter example")}

104 |

{React.string("Sum")}

105 | 106 |

{React.string("Counters sequence")}

107 | 108 |

{React.string("Counter controls")}

109 | 110 |
; 111 | }; 112 | }; 113 | 114 | ReactDOMRe.renderToElementWithId(, "app-root"); -------------------------------------------------------------------------------- /examples/counter/CounterStore.re: -------------------------------------------------------------------------------- 1 | type state = {counters: array(int)}; 2 | let initialValue = {counters: [|0, 0, 0, 0, 0|]}; 3 | 4 | type action = 5 | | AppendCounter 6 | | RemoveLastCounter 7 | | IncrementCounter(int) 8 | | DecrementCounter(int); 9 | 10 | module Lenses = { 11 | let counters = 12 | Refractive.Lens.make( 13 | ~get=x => x.counters, 14 | ~set=(newVal, _) => {counters: newVal}, 15 | ); 16 | }; 17 | 18 | module Selectors = { 19 | let (|-) = Refractive.Selector.compose; 20 | let counters = 21 | Refractive.Selector.make(~lens=Lenses.counters, ~path=[|"counter"|]); 22 | let countersCount = counters |- Refractive.Selector.arrayLength(0); 23 | let counterValue = i => counters |- Refractive.Selector.arrayIndex(i); 24 | }; 25 | 26 | // Module for tracked selectors and modifications 27 | // This module's `modify` and `set` functions must be used to update the state 28 | include Refractive.TrackedSelector.Make({}); 29 | 30 | let reducer = (state, action) => { 31 | Selectors.( 32 | switch (action) { 33 | | AppendCounter => modify(countersCount, count => count + 1, state) 34 | | RemoveLastCounter => modify(countersCount, count => count - 1, state) 35 | | IncrementCounter(index) => 36 | modify(counterValue(index), n => n + 1, state) 37 | | DecrementCounter(index) => 38 | modify(counterValue(index), n => n - 1, state) 39 | } 40 | ); 41 | }; 42 | 43 | let store = 44 | Reductive.Store.create(~reducer, ~preloadedState=initialValue, ()); -------------------------------------------------------------------------------- /examples/counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter example 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/grid-concurrent/GridConcurrentApp.re: -------------------------------------------------------------------------------- 1 | ReactDOMRe.renderToElementWithId( 2 | , 3 | "app-root", 4 | ); -------------------------------------------------------------------------------- /examples/grid-concurrent/ReactConcurrentMode.re: -------------------------------------------------------------------------------- 1 | [@bs.obj] 2 | external makeProps: 3 | (~children: React.element=?, unit) => {. "children": option(React.element)} = 4 | ""; 5 | [@bs.module "react"] 6 | external make: React.component({. "children": option(React.element)}) = 7 | "unstable_ConcurrentMode"; -------------------------------------------------------------------------------- /examples/grid-concurrent/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Grid concurrent example 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/grid-reductive/GridReductiveApp.re: -------------------------------------------------------------------------------- 1 | module StoreContext = ReductiveContext.Make(GridReductiveStore); 2 | 3 | let useDispatch = StoreContext.useDispatch; 4 | let useSelector = StoreContext.useSelector; 5 | 6 | module GridCell = { 7 | let colors = [| 8 | "White", 9 | "LightCyan", 10 | "LightBlue", 11 | "LightGreen", 12 | "LightPink", 13 | "LightSalmon", 14 | "Orange", 15 | "PaleGreen", 16 | "Yellow", 17 | "Tan", 18 | |]; 19 | 20 | let style = n => 21 | ReactDOMRe.Style.make( 22 | ~padding="3px 8px", 23 | ~backgroundColor=colors[n], 24 | ~color="Black", 25 | ~display="inline-block", 26 | ~borderRadius=string_of_int(Random.int(12)) ++ "px", 27 | (), 28 | ); 29 | 30 | [@react.component] 31 | let make = (~index) => { 32 | let dispatch = useDispatch(); 33 | let selector = 34 | React.useCallback1( 35 | (state: GridReductiveStore.state) => 36 | Belt.Map.Int.getExn(state.cells, index), 37 | [|index|], 38 | ); 39 | let cycle = 40 | React.useCallback1(_ => dispatch(GridStore.Incr(index)), [|index|]); 41 | let value = useSelector(selector); 42 | let style = style(value); 43 | 44 | 45 | {React.string(string_of_int(value))} 46 | ; 47 | }; 48 | 49 | let make = React.memo(make); 50 | }; 51 | 52 | module GridRow = { 53 | [@react.component] 54 | let make = (~row) => { 55 |
56 | {React.array( 57 | Belt.Array.makeBy(GridReductiveStore.sideSize, col => 58 | 62 | ), 63 | )} 64 |
; 65 | }; 66 | 67 | let make = React.memo(make); 68 | }; 69 | 70 | module GridContainer = { 71 | let style = ReactDOMRe.Style.make(~fontFamily="Monospace", ()); 72 | 73 | [@react.component] 74 | let make = () => { 75 |
76 | {React.array( 77 | Belt.Array.makeBy(GridReductiveStore.sideSize, row => 78 | 79 | ), 80 | )} 81 |
; 82 | }; 83 | 84 | let make = React.memo(make); 85 | }; 86 | 87 | module App = { 88 | [@react.component] 89 | let make = () => { 90 | let dispatch = useDispatch(); 91 | let handleReset = React.useCallback0(_ => dispatch(Reset)); 92 | let handleRandomize = React.useCallback0(_ => dispatch(Randomize)); 93 | 94 | 95 |

{React.string("Grid example (without Refractive)")}

96 | 97 | 98 | 99 |
; 100 | }; 101 | }; 102 | 103 | ReactDOMRe.renderToElementWithId(, "app-root"); -------------------------------------------------------------------------------- /examples/grid-reductive/GridReductiveStore.re: -------------------------------------------------------------------------------- 1 | type state = GridStore.state; 2 | type action = GridStore.action; 3 | 4 | let sideSize = GridStore.sideSize; 5 | let initialValue = GridStore.initialValue; 6 | 7 | let reducer = (state: GridStore.state, action: GridStore.action) => { 8 | switch (action) { 9 | | Incr(idx) => 10 | let value = (Belt.Map.Int.getExn(state.cells, idx) + 1) mod 10; 11 | GridStore.{cells: Belt.Map.Int.set(state.cells, idx, value)}; 12 | | Reset => initialValue 13 | | Randomize => { 14 | cells: Belt.Map.Int.fromArray(Belt.Array.makeBy(sideSize * sideSize, idx => (idx, Random.int(10)))), 15 | } 16 | }; 17 | }; 18 | 19 | let store = 20 | Reductive.Store.create(~reducer, ~preloadedState=initialValue, ()); -------------------------------------------------------------------------------- /examples/grid-reductive/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Grid example 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/grid/GridApp.re: -------------------------------------------------------------------------------- 1 | module Selectors = GridStore.Selectors; 2 | module StoreContext = Refractive.Context.Make(GridStore); 3 | open StoreContext.Hooks; 4 | 5 | module GridCell = { 6 | let colors = [| 7 | "White", 8 | "LightCyan", 9 | "LightBlue", 10 | "LightGreen", 11 | "LightPink", 12 | "LightSalmon", 13 | "Orange", 14 | "PaleGreen", 15 | "Yellow", 16 | "Tan", 17 | |]; 18 | 19 | let style = n => 20 | ReactDOMRe.Style.make( 21 | ~padding="3px 8px", 22 | ~backgroundColor=colors[n], 23 | ~color="Black", 24 | ~display="inline-block", 25 | ~borderRadius=string_of_int(Random.int(12)) ++ "px", 26 | (), 27 | ); 28 | 29 | [@react.component] 30 | let make = (~index) => { 31 | let dispatch = useDispatch(); 32 | let selector = 33 | React.useMemo1(() => Selectors.cellValue(index), [|index|]); 34 | let cycle = 35 | React.useCallback1(_ => dispatch(GridStore.Incr(index)), [|index|]); 36 | let value = useSelector(selector); 37 | let style = style(value); 38 | 39 | 40 | {React.string(string_of_int(value))} 41 | ; 42 | }; 43 | 44 | let make = React.memo(make); 45 | }; 46 | 47 | module GridRow = { 48 | [@react.component] 49 | let make = (~row) => { 50 |
51 | {React.array( 52 | Belt.Array.makeBy(GridStore.sideSize, col => 53 | 57 | ), 58 | )} 59 |
; 60 | }; 61 | 62 | let make = React.memo(make); 63 | }; 64 | 65 | module GridContainer = { 66 | let style = ReactDOMRe.Style.make(~fontFamily="Monospace", ()); 67 | 68 | [@react.component] 69 | let make = () => { 70 |
71 | {React.array( 72 | Belt.Array.makeBy(GridStore.sideSize, row => 73 | 74 | ), 75 | )} 76 |
; 77 | }; 78 | 79 | let make = React.memo(make); 80 | }; 81 | 82 | module App = { 83 | [@react.component] 84 | let make = () => { 85 | let dispatch = useDispatch(); 86 | let handleReset = React.useCallback0(_ => dispatch(Reset)); 87 | let handleRandomize = React.useCallback0(_ => dispatch(Randomize)); 88 | 89 | 90 |

{React.string("Grid example")}

91 | 92 | 93 | 94 |
; 95 | }; 96 | }; 97 | 98 | ReactDOMRe.renderToElementWithId(, "app-root"); -------------------------------------------------------------------------------- /examples/grid/GridStore.re: -------------------------------------------------------------------------------- 1 | let sideSize = 40; 2 | 3 | type state = {cells: Belt.Map.Int.t(int)}; 4 | let initialValue = { 5 | cells: Belt.Map.Int.fromArray(Belt.Array.makeBy(sideSize * sideSize, idx => (idx, 0))), 6 | }; 7 | 8 | type action = 9 | | Incr(int) 10 | | Reset 11 | | Randomize; 12 | 13 | module Lenses = { 14 | let cells = 15 | Refractive.Lens.make( 16 | ~get=x => x.cells, 17 | ~set=(newVal, _) => {cells: newVal}, 18 | ); 19 | let pvecIndex = idx => 20 | Refractive.Lens.make( 21 | ~get=pv => Belt.Map.Int.getExn(pv, idx), 22 | ~set=(newVal, pv) => Belt.Map.Int.set(pv, idx, newVal), 23 | ); 24 | }; 25 | 26 | module Selectors = { 27 | open Refractive.Selector; 28 | let (|-) = compose; 29 | let cells = make(~lens=Lenses.cells, ~path=[|"cells"|]); 30 | let pvecIndex = i => 31 | make( 32 | ~lens=Lenses.pvecIndex(i), 33 | ~path=[|"get(" ++ string_of_int(i) ++ ")"|], 34 | ); 35 | let cellValue = i => cells |- pvecIndex(i); 36 | }; 37 | 38 | // Module for tracked selectors and modifications 39 | // This module's `modify` and `set` functions must be used to update the state 40 | include Refractive.TrackedSelector.Make({}); 41 | 42 | let reducer = (state, action) => { 43 | Selectors.( 44 | switch (action) { 45 | | Incr(idx) => modify(cellValue(idx), v => (v + 1) mod 10, state) 46 | | Reset => set(cells, initialValue.cells, state) 47 | | Randomize => 48 | set( 49 | cells, 50 | Belt.Map.Int.fromArray(Belt.Array.makeBy(sideSize * sideSize, idx => (idx, Random.int(10)))), 51 | state, 52 | ) 53 | } 54 | ); 55 | }; 56 | 57 | let store = 58 | Reductive.Store.create(~reducer, ~preloadedState=initialValue, ()); -------------------------------------------------------------------------------- /examples/grid/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Grid example 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tizoc/refractive", 3 | "version": "0.0.7", 4 | "description": "Lenses and tracked selectors enhancer and hooks for Reductive", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "bsb -make-world", 8 | "clean": "bsb -clean-world", 9 | "test": "npm run build && jest --silent", 10 | "webpack": "webpack", 11 | "webpack-server": "webpack -w" 12 | }, 13 | "keywords": [ 14 | "reason", 15 | "reasonml", 16 | "ocaml", 17 | "redux", 18 | "reductive", 19 | "lenses", 20 | "state management" 21 | ], 22 | "homepage": "https://github.com/tizoc/refractive", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/tizoc/refractive.git" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "author": "tizoc", 31 | "license": "BSD-3-Clause", 32 | "peerDependencies": { 33 | "reason-react": "^0.7.0", 34 | "reductive": "^2.0.1" 35 | }, 36 | "devDependencies": { 37 | "@glennsl/bs-jest": "^0.5.0", 38 | "bs-platform": "^7.2.2", 39 | "bs-react-testing-library": "^0.6.0", 40 | "react": "^16.13.1", 41 | "react-dom": "^16.13.1", 42 | "reason-hooks-testing-library": "^0.2.1", 43 | "reason-react": "^0.7.0", 44 | "reductive": "^2.0.1", 45 | "webpack": "^4.42.1", 46 | "webpack-cli": "^3.3.11" 47 | }, 48 | "dependencies": { 49 | "use-subscription": "^1.4.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Refractive.re: -------------------------------------------------------------------------------- 1 | module Store = Reductive.Store; 2 | module Selector = Refractive__Selector; 3 | module TrackedSelector = Refractive__TrackedSelector; 4 | module Lens = Refractive__Lens; 5 | module Context = Refractive__Context; -------------------------------------------------------------------------------- /src/Refractive.rei: -------------------------------------------------------------------------------- 1 | module Store = Reductive.Store; 2 | 3 | module Lens: { 4 | type lens('childOut, 'childIn, 'parentOut, 'parentIn); 5 | type t('parent, 'child) = lens('child, 'child, 'parent, 'parent); 6 | 7 | let make: 8 | (~get: 'parent => 'child, ~set: ('child, 'parent) => 'parent) => 9 | t('parent, 'child); 10 | 11 | let lens: 12 | ('parentIn => ('childIn, 'childOut => 'parentOut)) => 13 | lens('childOut, 'childIn, 'parentOut, 'parentIn); 14 | 15 | // Operations 16 | let view: (t('parent, 'child), 'parent) => 'child; 17 | let set: (t('parent, 'child), 'child, 'parent) => 'parent; 18 | let modify: (t('parent, 'child), 'child => 'child, 'parent) => 'parent; 19 | let compose: 20 | (t('parent, 'child), t('child, 'grandchild)) => t('parent, 'grandchild); 21 | 22 | let const: 'child => t('parent, 'child); 23 | 24 | let map: ('child => 'mapped, t('parent, 'child)) => t('parent, 'mapped); 25 | let map2: 26 | ( 27 | ('child1, 'child2) => 'mapped, 28 | t('parent, 'child1), 29 | t('parent, 'child2) 30 | ) => 31 | t('parent, 'mapped); 32 | let map3: 33 | ( 34 | ('child1, 'child2, 'child3) => 'mapped, 35 | t('parent, 'child1), 36 | t('parent, 'child2), 37 | t('parent, 'child3) 38 | ) => 39 | t('parent, 'mapped); 40 | let map4: 41 | ( 42 | ('child1, 'child2, 'child3, 'child4) => 'mapped, 43 | t('parent, 'child1), 44 | t('parent, 'child2), 45 | t('parent, 'child3), 46 | t('parent, 'child4) 47 | ) => 48 | t('parent, 'mapped); 49 | 50 | let pair: 51 | (t('parent, 'leftChild), t('parent, 'rightChild)) => 52 | t('parent, ('leftChild, 'rightChild)); 53 | 54 | // Default lenses 55 | let arrayIndex: int => t(array('child), 'child); 56 | let arrayLength: 'filler => t(array('filler), int); 57 | }; 58 | 59 | module Selector: { 60 | type t('parent, 'child); 61 | 62 | let make: 63 | (~lens: Lens.t('parent, 'child), ~path: array(string)) => 64 | t('parent, 'child); 65 | 66 | // Operations 67 | let view: (t('parent, 'child), 'parent) => 'child; 68 | let set: (t('parent, 'child), 'child, 'parent) => 'parent; 69 | let modify: (t('parent, 'child), 'child => 'child, 'parent) => 'parent; 70 | let compose: 71 | (t('parent, 'child), t('child, 'grandchild)) => t('parent, 'grandchild); 72 | 73 | let const: (~name: string=?, 'child) => t('parent, 'child); 74 | 75 | let map: 76 | (~name: string, 'child => 'mapped, t('parent, 'child)) => 77 | t('parent, 'mapped); 78 | let map2: 79 | ( 80 | ~name: string, 81 | ('child1, 'child2) => 'mapped, 82 | t('parent, 'child1), 83 | t('parent, 'child2) 84 | ) => 85 | t('parent, 'mapped); 86 | let map3: 87 | ( 88 | ~name: string, 89 | ('child1, 'child2, 'child3) => 'mapped, 90 | t('parent, 'child1), 91 | t('parent, 'child2), 92 | t('parent, 'child3) 93 | ) => 94 | t('parent, 'mapped); 95 | let map4: 96 | ( 97 | ~name: string, 98 | ('child1, 'child2, 'child3, 'child4) => 'mapped, 99 | t('parent, 'child1), 100 | t('parent, 'child2), 101 | t('parent, 'child3), 102 | t('parent, 'child4) 103 | ) => 104 | t('parent, 'mapped); 105 | 106 | let pair: 107 | (t('parent, 'leftChild), t('parent, 'rightChild)) => 108 | t('parent, ('leftChild, 'rightChild)); 109 | 110 | // Default lenses 111 | let arrayIndex: int => t(array('a), 'a); 112 | let arrayLength: 'filler => t(array('filler), int); 113 | }; 114 | 115 | module TrackedSelector: { 116 | module Make: 117 | () => 118 | { 119 | let set: (Selector.t('parent, 'child), 'child, 'parent) => 'parent; 120 | let modify: 121 | (Selector.t('parent, 'child), 'child => 'child, 'parent) => 'parent; 122 | let subscribe: 123 | (Selector.t('parent, 'child), unit => unit, unit) => unit; 124 | let notify: unit => unit; 125 | }; 126 | }; 127 | 128 | module Context: { 129 | module type CONFIG = { 130 | type state; 131 | type action; 132 | let store: Store.t(action, state); 133 | let subscribe: (Selector.t(state, 'a), unit => unit, unit) => unit; 134 | let notify: unit => unit; 135 | }; 136 | 137 | module Make: 138 | (Config: CONFIG) => 139 | { 140 | module Provider: { 141 | [@react.component] 142 | let make: (~children: React.element) => React.element; 143 | }; 144 | 145 | module Hooks: { 146 | let useDispatch: (unit, Config.action) => unit; 147 | let useSelector: Selector.t(Config.state, 'value) => 'value; 148 | }; 149 | }; 150 | }; -------------------------------------------------------------------------------- /src/Refractive__Context.re: -------------------------------------------------------------------------------- 1 | module Store = Reductive.Store; 2 | module Selector = Refractive__Selector; 3 | 4 | let useSubscription = Refractive__Subscription.useSubscription; 5 | 6 | module type CONFIG = { 7 | type state; 8 | type action; 9 | 10 | let store: Store.t(action, state); 11 | 12 | let subscribe: (Selector.t(state, 'a), unit => unit, unit) => unit; 13 | let notify: unit => unit; 14 | }; 15 | 16 | module Make = (Config: CONFIG) => { 17 | let context = React.createContext(Config.store); 18 | 19 | Store.subscribe(Config.store, Config.notify); 20 | 21 | module Provider = { 22 | [@react.component] 23 | let make = (~children) => { 24 | React.createElement( 25 | context->React.Context.provider, 26 | {"value": Config.store, "children": children}, 27 | ); 28 | }; 29 | }; 30 | 31 | module Hooks = { 32 | let useDispatch = () => Store.dispatch(React.useContext(context)); 33 | 34 | let useSelector = selector => { 35 | let store = React.useContext(context); 36 | let subscribe = 37 | React.useCallback1( 38 | handler => Config.subscribe(selector, handler), 39 | [|Selector.pathId(selector)|], 40 | ); 41 | let getCurrentValue = 42 | React.useCallback1( 43 | () => Selector.view(selector, Store.getState(store)), 44 | [|Selector.pathId(selector)|], 45 | ); 46 | useSubscription({getCurrentValue, subscribe}); 47 | }; 48 | }; 49 | }; -------------------------------------------------------------------------------- /src/Refractive__Lens.re: -------------------------------------------------------------------------------- 1 | let (@.) = (g, f, x) => g(f(x)); 2 | 3 | type lens('childOut, 'childIn, 'parentOut, 'parentIn) = 4 | 'parentIn => ('childIn, 'childOut => 'parentOut); 5 | type t('parent, 'child) = lens('child, 'child, 'parent, 'parent); 6 | 7 | let make = (~get, ~set, t) => (get(t), set(_, t)); 8 | 9 | let lens = f => f; 10 | 11 | let view = lens => fst @. lens; 12 | 13 | let modify = (lens, f, state) => { 14 | let (value, set) = lens(state); 15 | set(f(value)); 16 | }; 17 | 18 | let set = (lens, value, state) => { 19 | let (_, set) = lens(state); 20 | set(value); 21 | }; 22 | 23 | let compose = (outerLens, innerLens, parent) => { 24 | let (outerChild, setOuter) = outerLens(parent); 25 | let (innerChild, setInner) = innerLens(outerChild); 26 | (innerChild, setOuter @. setInner); 27 | }; 28 | 29 | // TODO: raise exception when trying to set (or log a warning) 30 | let const = (value, state) => (value, _ => state); 31 | 32 | let pair = (leftLens, rightLens, parent) => { 33 | let (leftChild, setLeft) = leftLens(parent); 34 | let (rightChild, _) = rightLens(parent); 35 | let setPairs = ((l, r)) => { 36 | let parentLeftUpdated = setLeft(l); 37 | let (_, setRight) = rightLens(parentLeftUpdated); 38 | setRight(r); 39 | }; 40 | ((leftChild, rightChild), setPairs); 41 | }; 42 | 43 | let map = (f, lens, parent) => { 44 | let (value, _) = lens(parent); 45 | (f(value), _ => assert(false)); 46 | }; 47 | 48 | let map2 = (f, lens1, lens2, parent) => { 49 | let (value1, _) = lens1(parent); 50 | let (value2, _) = lens2(parent); 51 | (f(value1, value2), _ => assert(false)); 52 | }; 53 | 54 | let map3 = (f, lens1, lens2, lens3, parent) => { 55 | let (value1, _) = lens1(parent); 56 | let (value2, _) = lens2(parent); 57 | let (value3, _) = lens3(parent); 58 | (f(value1, value2, value3), _ => assert(false)); 59 | }; 60 | 61 | let map4 = (f, lens1, lens2, lens3, lens4, parent) => { 62 | let (value1, _) = lens1(parent); 63 | let (value2, _) = lens2(parent); 64 | let (value3, _) = lens3(parent); 65 | let (value4, _) = lens4(parent); 66 | (f(value1, value2, value3, value4), _ => assert(false)); 67 | }; 68 | 69 | // Default lenses 70 | 71 | let arrayIndex = (i, arr) => ( 72 | arr[i], 73 | value => { 74 | let result = Array.copy(arr); 75 | result[i] = value; 76 | result; 77 | }, 78 | ); 79 | 80 | let arrayLength = (filler, arr) => ( 81 | Array.length(arr), 82 | length => { 83 | let current = Array.length(arr); 84 | if (length >= current) { 85 | Array.append(arr, Array.make(length - current, filler)); 86 | } else { 87 | Array.sub(arr, 0, max(0, length)); 88 | }; 89 | }, 90 | ); -------------------------------------------------------------------------------- /src/Refractive__Selector.re: -------------------------------------------------------------------------------- 1 | module Lens = Refractive__Lens; 2 | 3 | let (@.) = (g, f, x) => g(f(x)); 4 | 5 | let sep = "."; 6 | let join = (a, b) => String.concat(sep, [a, b]); 7 | 8 | type straightPath = array(string); // path fragments are stored in reverse 9 | 10 | // Selector paths are either straight or forked 11 | // Forked paths have: 12 | // - a base 13 | // - that has many forks 14 | // - which are combined back by a joiner 15 | // - followed by an optional inner path 16 | type selectorPath = 17 | | Straight(straightPath) 18 | | Forked(straightPath, list(selectorPath), string, option(selectorPath)); 19 | 20 | type t('state, 'value) = { 21 | lens: Lens.t('state, 'value), 22 | path: selectorPath, 23 | pathString: string, 24 | affectedPaths: array(string), 25 | observedPaths: array(string), 26 | }; 27 | 28 | let path = s => s.path; 29 | let pathId = s => s.pathString; 30 | let affectedPaths = s => s.affectedPaths; 31 | let observedPaths = s => s.observedPaths; 32 | 33 | let stringOfRawPath = String.concat(sep) @. List.rev; 34 | 35 | let pathsEqual = (p1, p2) => Belt.Array.eq(p1, p2, String.equal); 36 | 37 | let joinPath = (parent, child) => 38 | switch (parent, child) { 39 | | (p, "") => p 40 | | ("", c) => c 41 | | (p, c) => join(p, c) 42 | }; 43 | 44 | // Given a path of array(string) fragments, 45 | // create all subpaths from the full path to the root. 46 | let rec unfoldFragments = fragments => 47 | switch (fragments) { 48 | | [] => [] 49 | | [fragment] => [fragment] 50 | | [_, ...rest] as fragments => [ 51 | stringOfRawPath(fragments), 52 | ...unfoldFragments(rest), 53 | ] 54 | }; 55 | 56 | let lengthCmp = (a, b) => 57 | switch (compare(String.length(a), String.length(b))) { 58 | | 0 => String.compare(a, b) 59 | | n => - n 60 | }; 61 | 62 | let rec unfoldPath = path => 63 | switch (path) { 64 | | Straight(path) => path |> Array.to_list |> unfoldFragments 65 | | Forked(basePath, forks, _joiner, _innerPath) => 66 | // TODO: joiner and innerPath? 67 | let unfoldedBasePath = basePath |> Array.to_list |> unfoldFragments; 68 | let unfoldedForkPaths = List.map(unfoldPath, forks) |> List.concat; 69 | let stringBasePath = stringOfRawPath(Array.to_list(basePath)); 70 | let unfoldedPaths = 71 | List.map(joinPath(stringBasePath), unfoldedForkPaths); 72 | List.sort_uniq(lengthCmp, unfoldedPaths @ unfoldedBasePath); 73 | }; 74 | 75 | let rec validatePath = path => 76 | switch (path) { 77 | | Straight(path) => 78 | if (Array.length(path) < 1) { 79 | invalid_arg("Selector path array must contain at least one element"); 80 | } 81 | | Forked(_basePath, forks, joiner, innerPath) => 82 | List.iter(validatePath, forks); 83 | Belt.Option.forEach(innerPath, validatePath); 84 | if (String.length(joiner) < 1) { 85 | invalid_arg("Selector joiner cannot be empty"); 86 | }; 87 | }; 88 | 89 | let rec stringOfPath = path => { 90 | switch (path) { 91 | | Straight(path) => stringOfRawPath(Array.to_list(path)) 92 | | Forked(basePath, forks, joiner, innerPath) => 93 | let forked = String.concat(", ", List.map(stringOfPath, forks)); 94 | let joined = joiner ++ "(" ++ forked ++ ")"; 95 | let innerPathString = 96 | switch (innerPath) { 97 | | None => joined 98 | | Some(innerPath) => joined ++ sep ++ stringOfPath(innerPath) 99 | }; 100 | switch (Array.to_list(basePath)) { 101 | | [] => innerPathString 102 | | rawPath => stringOfRawPath(rawPath) ++ sep ++ innerPathString 103 | }; 104 | }; 105 | }; 106 | 107 | let _make = (~lens, ~path) => { 108 | validatePath(path); 109 | let pathAndParents = unfoldPath(path); 110 | let pathString = List.hd(pathAndParents); 111 | let affectedPaths = 112 | Array.of_list([join(pathString, "*"), ...pathAndParents]); 113 | let observedPaths = 114 | Array.of_list([ 115 | pathString, 116 | ...List.map(p => join(p, "*"), List.tl(pathAndParents)), 117 | ]); 118 | {lens, path, pathString, affectedPaths, observedPaths}; 119 | }; 120 | 121 | let make = (~lens, ~path) => { 122 | _make(~lens, ~path=Straight(path)); 123 | }; 124 | 125 | let view = s => Lens.view(s.lens); 126 | let modify = s => Lens.modify(s.lens); 127 | let set = s => Lens.set(s.lens); 128 | 129 | let rec composePath = (outerPath, innerPath) => 130 | switch (outerPath, innerPath) { 131 | | (Straight(outerPath), Straight(innerPath)) => 132 | Straight(Array.append(innerPath, outerPath)) 133 | | (Straight(outerPath), Forked(forkedBasePath, forks, joiner, innerPath)) => 134 | Forked(Array.append(forkedBasePath, outerPath), forks, joiner, innerPath) 135 | | (Forked(outerBasePath, outerForkedPaths, joiner, None), moreInnerPath) => 136 | Forked(outerBasePath, outerForkedPaths, joiner, Some(moreInnerPath)) 137 | | ( 138 | Forked(outerBasePath, outerForkedPaths, joiner, Some(innerPath)), 139 | moreInnerPath, 140 | ) => 141 | Forked( 142 | outerBasePath, 143 | outerForkedPaths, 144 | joiner, 145 | Some(composePath(innerPath, moreInnerPath)), 146 | ) 147 | }; 148 | 149 | let compose = (outerSelector, innerSelector) => { 150 | let lens = Lens.compose(outerSelector.lens, innerSelector.lens); 151 | let path = composePath(outerSelector.path, innerSelector.path); 152 | _make(~lens, ~path); 153 | }; 154 | 155 | let const = (~name="$const", value) => make(~lens=Lens.const(value), ~path=[|name|]); 156 | 157 | let pair = (leftSelector, rightSelector) => { 158 | let lens = Lens.pair(leftSelector.lens, rightSelector.lens); 159 | let path = 160 | Forked([||], [leftSelector.path, rightSelector.path], "pair", None); 161 | _make(~lens, ~path); 162 | }; 163 | 164 | let map = (~name, f, selector) => { 165 | let lens = Lens.map(f, selector.lens); 166 | let path = composePath(selector.path, Straight([|name|])); 167 | _make(~lens, ~path); 168 | }; 169 | 170 | let map2 = (~name, f, selector1, selector2) => { 171 | let lens = Lens.map2(f, selector1.lens, selector2.lens); 172 | let path = Forked([||], [selector1.path, selector2.path], name, None); 173 | _make(~lens, ~path); 174 | }; 175 | 176 | let map3 = (~name, f, selector1, selector2, selector3) => { 177 | let lens = Lens.map3(f, selector1.lens, selector2.lens, selector3.lens); 178 | let path = 179 | Forked( 180 | [||], 181 | [selector1.path, selector2.path, selector3.path], 182 | name, 183 | None, 184 | ); 185 | _make(~lens, ~path); 186 | }; 187 | 188 | let map4 = (~name, f, selector1, selector2, selector3, selector4) => { 189 | let lens = 190 | Lens.map4( 191 | f, 192 | selector1.lens, 193 | selector2.lens, 194 | selector3.lens, 195 | selector4.lens, 196 | ); 197 | let path = 198 | Forked( 199 | [||], 200 | [selector1.path, selector2.path, selector3.path, selector4.path], 201 | name, 202 | None, 203 | ); 204 | _make(~lens, ~path); 205 | }; 206 | // Selector wrappers for default lenses 207 | 208 | let arrayIndex = i => 209 | make( 210 | ~lens=Lens.arrayIndex(i), 211 | ~path=[|"get(" ++ string_of_int(i) ++ ")"|], 212 | ); 213 | 214 | let arrayLength = filler => 215 | make(~lens=Lens.arrayLength(filler), ~path=[|"length"|]); -------------------------------------------------------------------------------- /src/Refractive__Subscription.re: -------------------------------------------------------------------------------- 1 | // Bindigs to useSubscription by @MargaretKrutikova 2 | // https://github.com/MargaretKrutikova/reductive/blob/0c2f20acf399d195586f4f3eaf16008a70da3a76/src/subscription.re 3 | [@bs.deriving {jsConverter: newType}] 4 | type source('a) = { 5 | subscribe: (unit => unit, unit) => unit, 6 | getCurrentValue: unit => 'a, 7 | }; 8 | 9 | [@bs.module "use-subscription"] 10 | external useSubscriptionJs: abs_source('a) => 'a = "useSubscription"; 11 | 12 | let useSubscription = source => { 13 | let sourceJs = React.useMemo1(() => sourceToJs(source), [|source|]); 14 | useSubscriptionJs(sourceJs); 15 | }; -------------------------------------------------------------------------------- /src/Refractive__TrackedSelector.re: -------------------------------------------------------------------------------- 1 | module Selector = Refractive__Selector; 2 | 3 | module L = Belt.HashMap.String; 4 | module M = Belt.Set.String; 5 | 6 | module Make = (()) => { 7 | let listeners: L.t(list(unit => unit)) = L.make(~hintSize=250); 8 | let modifications = ref(M.empty); 9 | 10 | let touch = selector => 11 | modifications := 12 | Array.fold_left( 13 | M.add, 14 | modifications^, 15 | Selector.affectedPaths(selector), 16 | ); 17 | 18 | let set = (selector, value, state) => { 19 | touch(selector); 20 | Selector.set(selector, value, state); 21 | }; 22 | 23 | let modify = (selector, f, state) => { 24 | touch(selector); 25 | Selector.modify(selector, f, state); 26 | }; 27 | 28 | let unsubscribe = (selector, listener, ()) => { 29 | selector 30 | |> Selector.observedPaths 31 | |> Array.iter(path => 32 | switch (L.get(listeners, path)) { 33 | | None => () 34 | | Some(pathListeners) => 35 | let matchedListeners = 36 | List.filter(l => listener !== l, pathListeners); 37 | L.set(listeners, path, matchedListeners); 38 | } 39 | ); 40 | }; 41 | 42 | let subscribe = (selector, listener) => { 43 | selector 44 | |> Selector.observedPaths 45 | |> Array.iter(path => { 46 | let pathListeners = 47 | Belt.Option.getWithDefault(L.get(listeners, path), []); 48 | L.set(listeners, path, pathListeners @ [listener]); 49 | }); 50 | unsubscribe(selector, listener); 51 | }; 52 | 53 | let notifyAllInPath = path => { 54 | switch (L.get(listeners, path)) { 55 | | None => () 56 | | Some(pathListeners) => List.iter(listener => listener(), pathListeners) 57 | }; 58 | }; 59 | 60 | let notify = () => { 61 | let paths = modifications^; 62 | modifications := M.empty; 63 | M.forEach(paths, notifyAllInPath); 64 | }; 65 | }; -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | mode: "development", 5 | entry: { 6 | counter: "./lib/js/examples/counter/CounterApp.bs.js", 7 | grid: "./lib/js/examples/grid/GridApp.bs.js", 8 | gridConcurrent: "./lib/js/examples/grid-concurrent/GridConcurrentApp.bs.js", 9 | gridReductive: "./lib/js/examples/grid-reductive/GridReductiveApp.bs.js" 10 | }, 11 | output: { 12 | path: path.join(__dirname, "bundledOutputs"), 13 | filename: "[name].js" 14 | } 15 | } --------------------------------------------------------------------------------