├── examples ├── Index.re ├── AppStore.re ├── index.html ├── AppModel.re ├── Root.re └── Api.re ├── .gitignore ├── bsconfig.json ├── src ├── EffectManager.re └── Elmish.re ├── LICENSE ├── package.json └── README.md /examples/Index.re: -------------------------------------------------------------------------------- 1 | ReactDOMRe.renderToElementWithId( 2 | , 3 | "root", 4 | ); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .merlin 3 | .bsb.lock 4 | npm-debug.log 5 | /lib/bs/ 6 | /node_modules/ 7 | *.bs.js 8 | yarn-error.log 9 | .cache 10 | dist 11 | -------------------------------------------------------------------------------- /examples/AppStore.re: -------------------------------------------------------------------------------- 1 | include Elmish.Make({ 2 | type model = AppModel.model; 3 | type message = AppModel.message; 4 | 5 | let update = AppModel.update; 6 | let storeEnhancer = None; 7 | let initialModel = AppModel.initModel; 8 | }); 9 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ReasonReact Examples 6 | 7 | 8 | Component 1: 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reason-react-elmish", 3 | "reason": { 4 | "react-jsx": 3 5 | }, 6 | "refmt": 3, 7 | "sources": [ 8 | { 9 | "dir": "src", 10 | "subdirs": true 11 | }, 12 | { 13 | "dir": "examples", 14 | "type": "dev", 15 | "subdirs": true 16 | } 17 | ], 18 | "package-specs": [ 19 | { 20 | "module": "commonjs", 21 | "in-source": true 22 | } 23 | ], 24 | "bs-dependencies": ["reason-react", "reductive"], 25 | "bs-dev-dependencies": ["@glennsl/bs-jest", "@glennsl/bs-json", "bs-fetch"], 26 | "namespace": true, 27 | "suffix": ".bs.js" 28 | } 29 | -------------------------------------------------------------------------------- /examples/AppModel.re: -------------------------------------------------------------------------------- 1 | open Api; 2 | 3 | type message = 4 | | FetchUsersRequest 5 | | FetchUsersSuccess(userApiResponse) 6 | | FetchUsersError; 7 | 8 | type data = 9 | | NotAsked 10 | | Loading 11 | | Error 12 | | Success(userApiResponse); 13 | 14 | type model = {data}; 15 | 16 | let fetchUsers = dispatch => { 17 | Js.Promise.( 18 | Caller.fetchUsers() 19 | |> then_(response => dispatch(FetchUsersSuccess(response)) |> resolve) 20 | |> catch(_ => dispatch(FetchUsersError) |> resolve) 21 | ) 22 | |> ignore; 23 | None; 24 | }; 25 | 26 | let update = (_, message) => { 27 | switch (message) { 28 | | FetchUsersRequest => ({data: Loading}, Some(fetchUsers)) 29 | | FetchUsersSuccess(data) => ({data: Success(data)}, None) 30 | | FetchUsersError => ({data: Error}, None) 31 | }; 32 | }; 33 | 34 | let initModel = ({data: NotAsked}, None); 35 | -------------------------------------------------------------------------------- /examples/Root.re: -------------------------------------------------------------------------------- 1 | let selector = (model: AppModel.model) => model.data; 2 | 3 | [@react.component] 4 | let make = () => { 5 | let dispatch = AppStore.useDispatch(); 6 | let data = AppStore.useSelector(selector); 7 | 8 |
9 | 12 |
13 | {switch (data) { 14 | | Loading => React.string("Loading...") 15 | | Success(data) => 16 | data.users 17 | ->Belt.Array.map(user => 18 |
string_of_int}> 19 | {user.first_name ++ " " ++ user.last_name |> React.string} 20 |
21 | ) 22 | |> React.array 23 | | Error => React.string("Error :(") 24 | | NotAsked => React.string("No users to show.") 25 | }} 26 |
27 |
; 28 | }; 29 | -------------------------------------------------------------------------------- /src/EffectManager.re: -------------------------------------------------------------------------------- 1 | type effect = unit => option(unit => unit); 2 | 3 | type t = { 4 | listeners: ref(list(unit => unit)), 5 | effects: ref(array(effect)), 6 | }; 7 | let create = () => {listeners: ref([]), effects: ref([||])}; 8 | 9 | let getEffects = manager => manager.effects^; 10 | 11 | let unsubscribe = (manager, listener, ()) => 12 | manager.listeners := Belt.List.keep(manager.listeners^, l => listener !== l); 13 | 14 | let subscribe = (manager, listener) => { 15 | manager.listeners := [listener, ...manager.listeners^]; 16 | unsubscribe(manager, listener); 17 | }; 18 | 19 | let queueEffect = (manager, effect) => { 20 | manager.effects := Belt.Array.concat(manager.effects^, [|effect|]); 21 | Belt.List.forEach(manager.listeners^, listener => listener()); 22 | }; 23 | 24 | let runEffects = manager => { 25 | let cleanupFuncs = Belt.Array.keepMap(manager.effects^, func => func()); 26 | manager.effects := [||]; 27 | 28 | cleanupFuncs; 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Margarita Krutikova 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/Api.re: -------------------------------------------------------------------------------- 1 | type user = { 2 | id: int, 3 | email: string, 4 | first_name: string, 5 | last_name: string, 6 | avatar: string, 7 | }; 8 | 9 | let decodeUser = json => 10 | Json.Decode.{ 11 | id: json |> field("id", int), 12 | email: json |> field("email", string), 13 | first_name: json |> field("first_name", string), 14 | last_name: json |> field("last_name", string), 15 | avatar: json |> field("avatar", string), 16 | }; 17 | 18 | type userApiResponse = { 19 | page: int, 20 | per_page: int, 21 | total: int, 22 | total_pages: int, 23 | users: array(user), 24 | }; 25 | 26 | let decodeUserApiResponse = json => 27 | Json.Decode.{ 28 | page: json |> field("page", int), 29 | per_page: json |> field("per_page", int), 30 | total: json |> field("total", int), 31 | total_pages: json |> field("total_pages", int), 32 | users: json |> field("data", array(decodeUser)), 33 | }; 34 | 35 | module Caller: {let fetchUsers: unit => Js.Promise.t(userApiResponse);} = { 36 | let baseUrl = "https://reqres.in/api"; 37 | let fetchUsers = () => 38 | Js.Promise.( 39 | Fetch.fetch(baseUrl ++ "/users?per_page=12") 40 | |> then_(Fetch.Response.json) 41 | |> then_(json => json |> decodeUserApiResponse |> resolve) 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reason-react-elmish", 3 | "version": "0.1.0", 4 | "description": "Elmish architecture for reason-react", 5 | "main": "src/Elmish.re", 6 | "author": "Margarita Krutikova ", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "parcel examples/index.html", 10 | "prestart": "npm run clean:bs && npm run build:bs", 11 | "watch:bs": "bsb -make-world -w", 12 | "build": "parcel build examples/index.html", 13 | "prebuild": "npm run clean && npm run build:bs", 14 | "build:bs": "bsb -make-world", 15 | "clean": "npm run clean:bs && npm run clean:dist", 16 | "clean:bs": "bsb -clean-world", 17 | "clean:dist": "rm -rf dist", 18 | "test": "npm run build && jest" 19 | }, 20 | "homepage": "https://github.com/MargaretKrutikova/reason-react-elmish", 21 | "bugs": "https://github.com/MargaretKrutikova/reason-react-elmish/issues", 22 | "keywords": [ 23 | "react", 24 | "reason-react", 25 | "buckleScript", 26 | "ui", 27 | "elmish" 28 | ], 29 | "files": [ 30 | "src", 31 | "bsconfig.json" 32 | ], 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/MargaretKrutikova/reason-react-elmish.git" 36 | }, 37 | "peerDependencies": { 38 | "react": "^16.8.6", 39 | "bs-platform": "^5.0.6 || ^7.0.1", 40 | "reason-react": "^0.7.0" 41 | }, 42 | "devDependencies": { 43 | "@glennsl/bs-jest": "^0.4.8", 44 | "@glennsl/bs-json": "^5.0.1", 45 | "bs-fetch": "^0.5.0", 46 | "bs-platform": "^7.0.1", 47 | "parcel-bundler": "^1.12.4", 48 | "react": "^16.8.6", 49 | "reason-react": "^0.7.0" 50 | }, 51 | "dependencies": { 52 | "reductive": "^2.0.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reason-react-elmish 2 | 3 | The library implements the "model-update" part of the "model-view-update" elm architecture, leveraging React as the "view" part and implementing the missing part of state and effect management. 4 | 5 | 🚧 Not production ready (yet), but trying out is highly encouraged 🚧 6 | 7 | ## Installation 8 | 9 | Can be installed via: 10 | 11 | ```sh 12 | npm install --save "https://github.com/MargaretKrutikova/reason-react-elmish" 13 | ``` 14 | 15 | and added to bsconfig.json: 16 | 17 | ```sh 18 | "bs-dependencies": [ 19 | "reason-react-elmish" 20 | ] 21 | ``` 22 | 23 | Peer dependencies are `react`, `reason-react` and `bs-platform`. 24 | 25 | ## Example usage 26 | 27 | First, define your types for message and model, implement the update function and effectful functions (if any). 28 | 29 | ```reason 30 | // AppModel.re 31 | type message = 32 | | FetchUsersRequest 33 | | FetchUsersSuccess(userApiResponse) 34 | | FetchUsersError; 35 | 36 | type data = 37 | | NotAsked 38 | | Loading 39 | | Error 40 | | Success(userApiResponse); 41 | 42 | type model = {data}; 43 | 44 | let fetchUsers = dispatch => { 45 | Js.Promise.( 46 | Caller.fetchUsers() 47 | |> then_(response => dispatch(FetchUsersSuccess(response)) |> resolve) 48 | |> catch(_ => dispatch(FetchUsersError) |> resolve) 49 | ) 50 | |> ignore; 51 | None; 52 | }; 53 | 54 | let update = (_, message) => { 55 | switch (message) { 56 | | FetchUsersRequest => ({data: Loading}, Some(fetchUsers)) 57 | | FetchUsersSuccess(data) => ({data: Success(data)}, None) 58 | | FetchUsersError => ({data: Error}, None) 59 | }; 60 | }; 61 | 62 | let initModel = ({data: NotAsked}, None); 63 | ``` 64 | 65 | Setup your elmish store with all of the above and your initial model with initial effect (if any). 66 | 67 | ```reason 68 | // AppStore.re 69 | include Elmish.Make({ 70 | type model = AppModel.model; 71 | type message = AppModel.message; 72 | 73 | let update = AppModel.update; 74 | let storeEnhancer = None; 75 | let initialModel = AppModel.initModel; 76 | }); 77 | 78 | ``` 79 | 80 | See example file [`./examples/AppModel.re`](./examples/AppModel.re) and [`./examples/AppStore.re`](./examples/AppStore.re). 81 | 82 | Then hook in your store into the react component tree somewhere at the root: 83 | 84 | ```reason 85 | // Index.re 86 | ReactDOMRe.renderToElementWithId( 87 | , 88 | "root", 89 | ); 90 | ``` 91 | 92 | and use the hooks to get access to the model and dispatch in your components: 93 | 94 | ```reason 95 | let selector = (model: AppModel.model) => model.data; 96 | 97 | [@react.component] 98 | let make = () => { 99 | let dispatch = AppStore.useDispatch(); 100 | let result = AppStore.useSelector(selector); 101 | 102 |
103 | 106 |
; 107 | }; 108 | ``` 109 | 110 | ## Run examples 111 | 112 | ```sh 113 | npm install 114 | npm start 115 | # in another tab 116 | npm run watch:bs 117 | ``` 118 | -------------------------------------------------------------------------------- /src/Elmish.re: -------------------------------------------------------------------------------- 1 | type dispatch('message) = 'message => unit; 2 | type effect('message) = dispatch('message) => option(unit => unit); 3 | type update('model, 'message) = 4 | ('model, 'message) => ('model, option(effect('message))); 5 | 6 | type enhancer('action, 'state) = 7 | (Reductive.Store.t('action, 'state), 'action => unit, 'action) => unit; 8 | 9 | type storeEnhancer('action, 'state) = 10 | ( 11 | ~reducer: ('state, 'action) => 'state, 12 | ~preloadedState: 'state, 13 | ~enhancer: enhancer('action, 'state)=?, 14 | unit 15 | ) => 16 | Reductive.Store.t('action, 'state); 17 | 18 | module type Config = { 19 | type model; 20 | type message; 21 | 22 | let update: update(model, message); 23 | let initialModel: (model, option(effect(message))); 24 | let storeEnhancer: 25 | option(storeEnhancer(message, model) => storeEnhancer(message, model)); 26 | }; 27 | 28 | module Make = (Config: Config) => { 29 | open Reductive; 30 | include ReductiveContext.Make({ 31 | type state = Config.model; 32 | type action = Config.message; 33 | }); 34 | 35 | let effectManager = EffectManager.create(); 36 | 37 | let (initModel, initEffect) = Config.initialModel; 38 | 39 | let storeCreator = 40 | switch (Config.storeEnhancer) { 41 | | None => Store.create 42 | | Some(enhancer) => enhancer @@ Store.create 43 | }; 44 | 45 | let modelStore = 46 | storeCreator( 47 | ~reducer=(state, _) => state, 48 | ~preloadedState=initModel, 49 | (), 50 | ); 51 | 52 | let customReducer = (model, message) => { 53 | let (newModel, effect) = Config.update(model, message); 54 | switch (effect) { 55 | | Some(effectToRun) => 56 | EffectManager.queueEffect(effectManager, () => 57 | effectToRun(Store.dispatch(modelStore)) 58 | ) 59 | | None => () 60 | }; 61 | 62 | newModel; 63 | }; 64 | 65 | let dispatch = Store.dispatch(modelStore); 66 | 67 | Store.replaceReducer(modelStore, customReducer); 68 | 69 | switch (initEffect) { 70 | | Some(effect) => effect(Store.dispatch(modelStore)) 71 | | None => None 72 | }; 73 | 74 | let useRunEffects = () => { 75 | open Subscription; 76 | 77 | let source = 78 | React.useMemo0(() => 79 | { 80 | subscribe: EffectManager.subscribe(effectManager), 81 | getCurrentValue: () => EffectManager.getEffects(effectManager), 82 | } 83 | ); 84 | 85 | let effects = useSubscription(source); 86 | 87 | React.useEffect1( 88 | () => { 89 | let cleanupFuncs = EffectManager.runEffects(effectManager); 90 | Belt.Array.length(cleanupFuncs) > 0 91 | ? Some(() => Belt.Array.forEach(cleanupFuncs, func => func())) 92 | : None; 93 | }, 94 | [|effects|], 95 | ); 96 | }; 97 | 98 | module EffectRunner = { 99 | [@react.component] 100 | let make = (~children) => { 101 | useRunEffects(); 102 | children; 103 | }; 104 | }; 105 | 106 | module ElmishProvider = { 107 | [@react.component] 108 | let make = (~children) => { 109 | 110 | children 111 | ; 112 | }; 113 | }; 114 | }; 115 | --------------------------------------------------------------------------------