diff --git a/templates/ReactReduxSpa/ClientApp/components/Counter.tsx b/templates/ReactReduxSpa/ClientApp/components/Counter.tsx index 6b8f05c..70cfbc7 100644 --- a/templates/ReactReduxSpa/ClientApp/components/Counter.tsx +++ b/templates/ReactReduxSpa/ClientApp/components/Counter.tsx @@ -1,8 +1,11 @@ import * as React from 'react'; import { Link } from 'react-router'; -import { provide } from 'redux-typed'; +import { connect } from 'react-redux'; import { ApplicationState } from '../store'; import * as CounterStore from '../store/Counter'; +import * as WeatherForecasts from '../store/WeatherForecasts'; + +type CounterProps = CounterStore.CounterState & typeof CounterStore.actionCreators; class Counter extends React.Component { public render() { @@ -18,10 +21,8 @@ class Counter extends React.Component { } } -// Build the CounterProps type, which allows the component to be strongly typed -const provider = provide( - (state: ApplicationState) => state.counter, // Select which part of global state maps to this component - CounterStore.actionCreators // Select which action creators should be exposed to this component -); -type CounterProps = typeof provider.allProps; -export default provider.connect(Counter); +// Wire up the React component to the Redux store +export default connect( + (state: ApplicationState) => state.counter, // Selects which state properties are merged into the component's props + CounterStore.actionCreators // Selects which action creators are merged into the component's props +)(Counter); diff --git a/templates/ReactReduxSpa/ClientApp/components/FetchData.tsx b/templates/ReactReduxSpa/ClientApp/components/FetchData.tsx index d204029..649d070 100644 --- a/templates/ReactReduxSpa/ClientApp/components/FetchData.tsx +++ b/templates/ReactReduxSpa/ClientApp/components/FetchData.tsx @@ -1,20 +1,22 @@ import * as React from 'react'; import { Link } from 'react-router'; -import { provide } from 'redux-typed'; +import { connect } from 'react-redux'; import { ApplicationState } from '../store'; import * as WeatherForecastsState from '../store/WeatherForecasts'; -interface RouteParams { - startDateIndex: string; -} +// At runtime, Redux will merge together... +type WeatherForecastProps = + WeatherForecastsState.WeatherForecastsState // ... state we've requested from the Redux store + & typeof WeatherForecastsState.actionCreators // ... plus action creators we've requested + & { params: { startDateIndex: string } }; // ... plus incoming routing parameters class FetchData extends React.Component { componentWillMount() { - // This method runs when the component is first added to the page + // This method runs when the component is first added to the page let startDateIndex = parseInt(this.props.params.startDateIndex) || 0; this.props.requestWeatherForecasts(startDateIndex); } - + componentWillReceiveProps(nextProps: WeatherForecastProps) { // This method runs when incoming props (e.g., route params) change let startDateIndex = parseInt(nextProps.params.startDateIndex) || 0; @@ -52,7 +54,7 @@ class FetchData extends React.Component { ; } - + private renderPagination() { let prevStartDateIndex = this.props.startDateIndex - 5; let nextStartDateIndex = this.props.startDateIndex + 5; @@ -65,10 +67,7 @@ class FetchData extends React.Component { } } -// Build the WeatherForecastProps type, which allows the component to be strongly typed -const provider = provide( - (state: ApplicationState) => state.weatherForecasts, // Select which part of global state maps to this component - WeatherForecastsState.actionCreators // Select which action creators should be exposed to this component -).withExternalProps<{ params: RouteParams }>(); // Also include a 'params' property on WeatherForecastProps -type WeatherForecastProps = typeof provider.allProps; -export default provider.connect(FetchData); +export default connect( + (state: ApplicationState) => state.weatherForecasts, // Selects which state properties are merged into the component's props + WeatherForecastsState.actionCreators // Selects which action creators are merged into the component's props +)(FetchData); diff --git a/templates/ReactReduxSpa/ClientApp/components/Home.tsx b/templates/ReactReduxSpa/ClientApp/components/Home.tsx index 5099562..2fd6dcd 100644 --- a/templates/ReactReduxSpa/ClientApp/components/Home.tsx +++ b/templates/ReactReduxSpa/ClientApp/components/Home.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -export default class Home extends React.Component { +export default class Home extends React.Component { public render() { return

Hello, world!

diff --git a/templates/ReactReduxSpa/ClientApp/components/NavMenu.tsx b/templates/ReactReduxSpa/ClientApp/components/NavMenu.tsx index e3158f6..010e506 100644 --- a/templates/ReactReduxSpa/ClientApp/components/NavMenu.tsx +++ b/templates/ReactReduxSpa/ClientApp/components/NavMenu.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Link } from 'react-router'; -export class NavMenu extends React.Component { +export class NavMenu extends React.Component { public render() { return
diff --git a/templates/ReactReduxSpa/ClientApp/configureStore.ts b/templates/ReactReduxSpa/ClientApp/configureStore.ts index b778845..5b38388 100644 --- a/templates/ReactReduxSpa/ClientApp/configureStore.ts +++ b/templates/ReactReduxSpa/ClientApp/configureStore.ts @@ -2,7 +2,6 @@ import { createStore, applyMiddleware, compose, combineReducers, GenericStoreEnh import thunk from 'redux-thunk'; import { routerReducer } from 'react-router-redux'; import * as Store from './store'; -import { typedToPlain } from 'redux-typed'; export default function configureStore(initialState?: Store.ApplicationState) { // Build middleware. These are functions that can process the actions before they reach the store. @@ -10,7 +9,7 @@ export default function configureStore(initialState?: Store.ApplicationState) { // If devTools is installed, connect to it const devToolsExtension = windowIfDefined && windowIfDefined.devToolsExtension as () => GenericStoreEnhancer; const createStoreWithMiddleware = compose( - applyMiddleware(thunk, typedToPlain), + applyMiddleware(thunk), devToolsExtension ? devToolsExtension() : f => f )(createStore); diff --git a/templates/ReactReduxSpa/ClientApp/store/Counter.ts b/templates/ReactReduxSpa/ClientApp/store/Counter.ts index 67eee6b..18284c0 100644 --- a/templates/ReactReduxSpa/ClientApp/store/Counter.ts +++ b/templates/ReactReduxSpa/ClientApp/store/Counter.ts @@ -1,5 +1,4 @@ -import { typeName, isActionType, Action, Reducer } from 'redux-typed'; -import { ActionCreator } from './'; +import { Action, Reducer, ThunkAction } from 'redux'; // ----------------- // STATE - This defines the type of data maintained in the Redux store. @@ -13,25 +12,34 @@ export interface CounterState { // They do not themselves have any side-effects; they just describe something that is going to happen. // Use @typeName and isActionType for type detection that works even after serialization/deserialization. -@typeName("INCREMENT_COUNT") -class IncrementCount extends Action { -} +interface IncrementCountAction { type: 'INCREMENT_COUNT' } +interface DecrementCountAction { type: 'DECREMENT_COUNT' } + +// Declare a 'discriminated union' type. This guarantees that all references to 'type' properties contain one of the +// declared type strings (and not any other arbitrary string). +type KnownAction = IncrementCountAction | DecrementCountAction; // ---------------- // ACTION CREATORS - These are functions exposed to UI components that will trigger a state transition. // They don't directly mutate state, but they can have external side-effects (such as loading data). export const actionCreators = { - increment: (): ActionCreator => (dispatch, getState) => { - dispatch(new IncrementCount()); - } + increment: () => { type: 'INCREMENT_COUNT' }, + decrement: () => { type: 'DECREMENT_COUNT' } }; // ---------------- // REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state. -export const reducer: Reducer = (state, action) => { - if (isActionType(action, IncrementCount)) { - return { count: state.count + 1 }; + +export const reducer: Reducer = (state: CounterState, action: KnownAction) => { + switch (action.type) { + case 'INCREMENT_COUNT': + return { count: state.count + 1 }; + case 'DECREMENT_COUNT': + return { count: state.count - 1 }; + default: + // The following line guarantees that every action in the KnownAction union has been covered by a case above + const exhaustiveCheck: never = action; } // For unrecognized actions (or in cases where actions have no effect), must return the existing state diff --git a/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts b/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts index 3101a7d..d027d2a 100644 --- a/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts +++ b/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts @@ -1,6 +1,6 @@ import { fetch, addTask } from 'domain-task'; -import { typeName, isActionType, Action, Reducer } from 'redux-typed'; -import { ActionCreator } from './'; +import { Action, Reducer, ThunkAction, ActionCreator } from 'redux'; +import { AppThunkAction } from './'; // ----------------- // STATE - This defines the type of data maintained in the Redux store. @@ -21,57 +21,70 @@ export interface WeatherForecast { // ----------------- // ACTIONS - These are serializable (hence replayable) descriptions of state transitions. // They do not themselves have any side-effects; they just describe something that is going to happen. -// Use @typeName and isActionType for type detection that works even after serialization/deserialization. -@typeName("REQUEST_WEATHER_FORECASTS") -class RequestWeatherForecasts extends Action { - constructor(public startDateIndex: number) { - super(); - } +interface RequestWeatherForecastsAction { + type: 'REQUEST_WEATHER_FORECASTS', + startDateIndex: number; } -@typeName("RECEIVE_WEATHER_FORECASTS") -class ReceiveWeatherForecasts extends Action { - constructor(public startDateIndex: number, public forecasts: WeatherForecast[]) { - super(); - } +interface ReceiveWeatherForecastsAction { + type: 'RECEIVE_WEATHER_FORECASTS', + startDateIndex: number; + forecasts: WeatherForecast[] } +// Declare a 'discriminated union' type. This guarantees that all references to 'type' properties contain one of the +// declared type strings (and not any other arbitrary string). +type KnownAction = RequestWeatherForecastsAction | ReceiveWeatherForecastsAction; + // ---------------- // ACTION CREATORS - These are functions exposed to UI components that will trigger a state transition. // They don't directly mutate state, but they can have external side-effects (such as loading data). export const actionCreators = { - requestWeatherForecasts: (startDateIndex: number): ActionCreator => (dispatch, getState) => { + requestWeatherForecasts: (startDateIndex: number): AppThunkAction => (dispatch, getState) => { // Only load data if it's something we don't already have (and are not already loading) if (startDateIndex !== getState().weatherForecasts.startDateIndex) { let fetchTask = fetch(`/api/SampleData/WeatherForecasts?startDateIndex=${ startDateIndex }`) .then(response => response.json()) .then((data: WeatherForecast[]) => { - dispatch(new ReceiveWeatherForecasts(startDateIndex, data)); + dispatch({ type: 'RECEIVE_WEATHER_FORECASTS', startDateIndex: startDateIndex, forecasts: data }); }); addTask(fetchTask); // Ensure server-side prerendering waits for this to complete - dispatch(new RequestWeatherForecasts(startDateIndex)); + dispatch({ type: 'REQUEST_WEATHER_FORECASTS', startDateIndex: startDateIndex }); } } }; // ---------------- // REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state. + const unloadedState: WeatherForecastsState = { startDateIndex: null, forecasts: [], isLoading: false }; -export const reducer: Reducer = (state, action) => { - if (isActionType(action, RequestWeatherForecasts)) { - return { startDateIndex: action.startDateIndex, isLoading: true, forecasts: state.forecasts }; - } else if (isActionType(action, ReceiveWeatherForecasts)) { - // Only accept the incoming data if it matches the most recent request. This ensures we correctly - // handle out-of-order responses. - if (action.startDateIndex === state.startDateIndex) { - return { startDateIndex: action.startDateIndex, forecasts: action.forecasts, isLoading: false }; - } + +export const reducer: Reducer = (state: WeatherForecastsState, action: KnownAction) => { + switch (action.type) { + case 'REQUEST_WEATHER_FORECASTS': + return { + startDateIndex: action.startDateIndex, + forecasts: state.forecasts, + isLoading: true + }; + case 'RECEIVE_WEATHER_FORECASTS': + // Only accept the incoming data if it matches the most recent request. This ensures we correctly + // handle out-of-order responses. + if (action.startDateIndex === state.startDateIndex) { + return { + startDateIndex: action.startDateIndex, + forecasts: action.forecasts, + isLoading: false + }; + } + break; + default: + // The following line guarantees that every action in the KnownAction union has been covered by a case above + const exhaustiveCheck: never = action; } - - // For unrecognized actions (or in cases where actions have no effect), must return the existing state - // (or default initial state if none was supplied) + return state || unloadedState; }; diff --git a/templates/ReactReduxSpa/ClientApp/store/index.ts b/templates/ReactReduxSpa/ClientApp/store/index.ts index 1f4798b..9016cbb 100644 --- a/templates/ReactReduxSpa/ClientApp/store/index.ts +++ b/templates/ReactReduxSpa/ClientApp/store/index.ts @@ -1,4 +1,3 @@ -import { ActionCreatorGeneric } from 'redux-typed'; import * as WeatherForecasts from './WeatherForecasts'; import * as Counter from './Counter'; @@ -18,4 +17,6 @@ export const reducers = { // This type can be used as a hint on action creators so that its 'dispatch' and 'getState' params are // correctly typed to match your store. -export type ActionCreator = ActionCreatorGeneric; +export interface AppThunkAction { + (dispatch: (action: TAction) => void, getState: () => ApplicationState): void; +} diff --git a/templates/ReactReduxSpa/package.json b/templates/ReactReduxSpa/package.json index 293c109..a5a1888 100644 --- a/templates/ReactReduxSpa/package.json +++ b/templates/ReactReduxSpa/package.json @@ -35,7 +35,6 @@ "react-router-redux": "^4.0.6", "redux": "^3.6.0", "redux-thunk": "^2.1.0", - "redux-typed": "^2.0.0", "style-loader": "^0.13.0", "ts-loader": "^0.8.1", "typescript": "2.0.3", diff --git a/templates/ReactReduxSpa/webpack.config.vendor.js b/templates/ReactReduxSpa/webpack.config.vendor.js index 96d6bc3..caad44b 100644 --- a/templates/ReactReduxSpa/webpack.config.vendor.js +++ b/templates/ReactReduxSpa/webpack.config.vendor.js @@ -15,7 +15,7 @@ module.exports = { ] }, entry: { - vendor: ['bootstrap', 'bootstrap/dist/css/bootstrap.css', 'domain-task', 'event-source-polyfill', 'react', 'react-dom', 'react-router', 'redux', 'redux-thunk', 'react-router-redux', 'redux-typed', 'style-loader', 'jquery'], + vendor: ['bootstrap', 'bootstrap/dist/css/bootstrap.css', 'domain-task', 'event-source-polyfill', 'react', 'react-dom', 'react-router', 'redux', 'redux-thunk', 'react-router-redux', 'style-loader', 'jquery'], }, output: { path: path.join(__dirname, 'wwwroot', 'dist'),