Example that demonstrates integration of reason into real-world pure react-redux app (electron) build on following stack:
- webpack
- typescript
- redux-observable (middleware)
- material-ui
- styled-components (css-in-js)
App is a minimal electron video player with reason used to implement Recent Videos component.
npm install -g reason-cli@3.2.0-darwin bs-platform
- make sure you've installed OCaml and Reason IDE extension for vscode or find one for your editor of choice: Editor Plugins
npm install
npm run bsb
for incremental builds of your reason code (orCmd+Shift+b
->b
->enter
in vscode)npm run dev
to launch webpack-dev-server with hot-reload (orCmd+Shift+b
->w
->enter
in vscode)
NOTE: middleware (redux-observable epics) will not hot reload, please usenpm run _dev-hot-epics
for this purpose, but keep in mind that it is GENERALLY UNSAFE.npm run electron
to launch app. (or F5 in vscode)
A simple and reasonable approach is wrap our reason component and pass it to react-redux
connect on .js/ts side. This way we only write component part in reason and our redux logic stays in js/ts. Seems ideal while getting started with reason.
let component = ReasonReact.statelessComponent("FilenameLabel");
let make = (~filename, ~className, _children) => {
...component,
render: _self =>
<span className style=(ReactDOMRe.Style.make(
~color="white",
~fontFamily="Roboto, \"Helvetica Neue\", sans-serif",
~fontSize="13px",
()
))>
(ReasonReact.string(filename))
</span>,
};
[@bs.deriving abstract]
type jsProps = {
filename: option(string),
className: string
};
let jsComponent = ReasonReact.wrapReasonForJs(~component, jsProps => make(
~className = jsProps |. classNameGet,
~filename = jsProps |. filenameGet |> Js.Option.getWithDefault("nothing loaded"),
[||],
));
const FilenameLabel = require('reason/components/FilenameLabel/FilenameLabel.bs.js').jsComponent
interface FilenameLabelProps extends StyledProps {}
const mapStateToProps = (state: State.Root, props: FilenameLabelProps) => ({
filename: state.media.sourceIdentifier ? state.media.sourceIdentifier.split('/').pop() : undefined
})
const mapDispatchToProps = (dispatch: Dispatch, props: FilenameLabelProps) => ({})
export default connect(mapStateToProps, mapDispatchToProps)(FilenameLabel)
Common way to define a set of related actions involves utilizing typescript's tagged union (discriminated union):
export enum ActionType {
play = 'play',
pause = 'pause'
}
export type MediaAction = PlayAction | PauseAction
export interface PlayAction extends Action {
type: ActionType.play,
id: string
}
export interface PauseAction extends Action {
type: ActionType.pause
id: string
}
Discriminated union (a.k.a. variant) is a build-in data structure in reason, and thus we can model the above thing as:
type mediaAction =
| Play(string)
| Pause(string);
Alright, but now we want that our mediaAction
actually maps to the above thing, so we can handle these actions in our existing reducers on js/ts side and debug in redux-dev-tools, but since for now bucklescript (ocalm-to-js compiler) cannot directly map variants to js-objects, we need to do the conversion ourselves. We utilize glennsl/bs-json for this purpose (resourceListAction.re):
type mediaAction =
| Play(string)
| Pause(string);
module Decode = {
open Json.Decode;
let mediAction = json => switch(json |> field("type", string)){
| "play" => Some(Play(
json |> field("id", string)))
| "pause" => Some(Pause(
json |> field("id", string)))
| _ => None
}
}
module Encode = {
open Json.Encode;
let mediaAction = action => switch(action){
| Play(id) => object_([
("type", string("play")),
("id", string(id))])
| Pause(id) => object_([
("type", string("play")),
("id", string(id))])
}
}
let toJs = action => action |> Encode.mediaAction;
let fromJs = js => js |> Decode.mediAction;
And thus we can then dispatch actions like:
dispatch(Play("my_media.mov") |> toJs)
Utilize [bs.deriving abstract]
(resourceListState.re).
[@bs.deriving abstract]
type state = {
resourcePaths: list(string),
visible: bool
};
let initial = () => state(
~resourcePaths=[],
~visible=false
);
Add it to our root state as any other substate, providing type definitions (root.ts):
import * as MediaState from './media'
const ResourceListState = require('reason/redux/state/resourceListState.bs').initial
export interface Root {
media: MediaState.Media,
// definition of our reason substate
resourceList: {
resourcePaths: string[],
visible: boolean
}
}
export const initial: () => Root = () => ({
media: MediaState.initial(),
resourceList: ResourceListState.initial()
})
Same as our js/ts reducer, though we need to convert action back to variant (resourceListReducer.re):
open List
open ResourceListState
open ResourceListAction
let reducer = (~lastState=initial(), ~action: Js.Json.t) =>
switch(action |> ResourceListAction.fromJs){
| None => lastState
| Some(LoadSourceDone(_sourceType, sourceIdentifier, _elementId, _duration)) =>
mem(sourceIdentifier, lastState|.resourcePathsGet)
? lastState
: state(
~resourcePaths=[sourceIdentifier, ...(lastState|.resourcePathsGet)],
~visible=lastState|.visibleGet
)
| Some(RemoveResource(resourcePath)) =>
state(
~resourcePaths=filter(res => res != resourcePath, lastState|.resourcePathsGet),
~visible=lastState|.visibleGet
)
| Some(SetVisibility(visible)) =>
state(
~resourcePaths=lastState|.resourcePathsGet,
~visible=visible
)
| _ => lastState
};
Add it to our root reducer as any other substate reducer (root.ts):
import { combineReducers } from 'redux'
import * as State from '../state'
import { media } from './media'
const resourceList = require('reason/redux/reducers/resourceListReducer.bs').reducer
export const root = combineReducers<State.Root>({
media,
resourceList
})
On reason side we now can call connect
, given that we have added the react-redux
bucklescript binding:
type mapStateToProps('state, 'props, 'connectedStateProps) = (. 'state, 'props) => 'connectedStateProps;
type mapDispatchToProps('action, 'props, 'connectedDispatchProps) = (. (. 'action) => unit, 'props) => 'connectedDispatchProps;
[@bs.module "react-redux"]
external connect: (
mapStateToProps('state, 'props, 'connectedStateProps),
mapDispatchToProps('action, 'props, 'connectedDispatchProps)
) => (. ReasonReact.reactClass) => ReasonReact.reactClass = "connect";
We also need to provide type definitions for the partial state that we want our reason code to access (rootState.re):
[@bs.deriving abstract]
type media = {
sourceIdentifier: Js.Nullable.t(string)
};
[@bs.deriving abstract]
type root = {
resourceList: ResourceListState.state,
media: media
};
And thus in our reason component (ResourceList.re):
[@bs.deriving abstract]
type stateProps = {
resourcePaths: list(string),
selectedResourcePath: option(string)
};
[@bs.deriving abstract]
type dispatchProps = {
selectResource: string => unit,
loadFromFile: unit => unit,
removeResource: string => unit
};
let connectedComponent = ReactRedux.connect(
(. state: RootState.root, ()) => stateProps(
~resourcePaths=state |. RootState.resourceListGet |. ResourceListState.resourcePathsGet,
~selectedResourcePath=(state |. RootState.mediaGet |. RootState.sourceIdentifierGet) |> Js.Nullable.toOption
),
(. dispatch, ()) => dispatchProps(
~selectResource=(resourcePath =>
dispatch(. LoadSourceFromFile("file", resourcePath, "video-context-main") |> toJs )
),
~loadFromFile=(() => Electron.showOpenDialog([`OpenFile], filePaths =>
length(filePaths) > 0 ?
dispatch(. LoadSourceFromFile("file", hd(filePaths), "video-context-main") |> toJs )
: ()
)),
~removeResource=(resourcePath =>
dispatch(. RemoveResource(resourcePath) |> toJs)
)
)
)(. jsComponent)
Material-UI bindings have been generated via reason-mui-binding-generator.
(1) npm install -g reason-cli@3.2.0-darwin
(2) install OCaml and Reason IDE extension in vscode
(3) install node dependencies:
// used as a dependency in emited .sj
npm install --g bs-platform
npm install --save reason-react
(4) Then place a bsconfig.json into root:
{
"name": "react-template",
"reason": {
"react-jsx": 2
},
"sources": {
"dir" : "src",
"subdirs" : true
},
"package-specs": [{
"module": "commonjs",
"in-source": false
}],
"suffix": ".bs.js",
"namespace": true,
"bs-dependencies": [
"reason-react"
],
"refmt": 3
}
(5) Add some reason source file and compile it with bsb -make-world
(6) Since we have specified "in-source": false
in the above config, all compilation output will go to lib/js
folder. Add aliases to webpack.config.babel.js
and tsconfig.json
so we can easier reference the compiled reason code in js and js code in reason:
in your webpack.config.babel.js
:
{
// webpack config
resolve: {
alias: {
reason: path.resolve(__dirname, "lib/js/src"),
src: path.resolve(__dirname, "src")
}
}
}
in your tsconfig.json
:
{
"compilerOptions": {
"paths": {
"reason": ["lib/js/src"],
"src": ["src"]
}
}
}
And then we can import reason compiled output like:
const FilenameLabel = require('reason/components/FilenameLabel/FilenameLabel.bs.js').jsComponent
Alternatively, if you don't really mind poluting your source folder with bucklescript compiled .js files, you can set "in-source": true
, which will have compiled output available alongside your source so relative import can be used:
const FilenameLabel = require('./FilenameLabel.bs.js').jsComponent